Refactoring

This commit is contained in:
DahoudG
2025-09-17 17:54:06 +00:00
parent 12d514d866
commit 63fe107f98
165 changed files with 54220 additions and 276 deletions

View File

@@ -0,0 +1,323 @@
import 'package:equatable/equatable.dart';
/// Énumération des types de métriques disponibles
enum TypeMetrique {
// Métriques membres
nombreMembresActifs('Nombre de membres actifs', 'membres', 'count'),
nombreMembresInactifs('Nombre de membres inactifs', 'membres', 'count'),
tauxCroissanceMembres('Taux de croissance des membres', 'membres', 'percentage'),
moyenneAgeMembres('Âge moyen des membres', 'membres', 'average'),
// Métriques financières
totalCotisationsCollectees('Total des cotisations collectées', 'finance', 'amount'),
cotisationsEnAttente('Cotisations en attente', 'finance', 'amount'),
tauxRecouvrementCotisations('Taux de recouvrement', 'finance', 'percentage'),
moyenneCotisationMembre('Cotisation moyenne par membre', 'finance', 'average'),
// Métriques événements
nombreEvenementsOrganises('Nombre d\'événements organisés', 'evenements', 'count'),
tauxParticipationEvenements('Taux de participation aux événements', 'evenements', 'percentage'),
moyenneParticipantsEvenement('Moyenne de participants par événement', 'evenements', 'average'),
// Métriques solidarité
nombreDemandesAide('Nombre de demandes d\'aide', 'solidarite', 'count'),
montantAidesAccordees('Montant des aides accordées', 'solidarite', 'amount'),
tauxApprobationAides('Taux d\'approbation des aides', 'solidarite', 'percentage');
const TypeMetrique(this.libelle, this.categorie, this.typeValeur);
final String libelle;
final String categorie;
final String typeValeur;
/// Retourne l'unité de mesure appropriée
String get unite {
switch (typeValeur) {
case 'percentage':
return '%';
case 'amount':
return 'XOF';
case 'average':
return typeValeur == 'moyenneAgeMembres' ? 'ans' : '';
default:
return '';
}
}
/// Retourne l'icône Material Design appropriée
String get icone {
switch (categorie) {
case 'membres':
return 'people';
case 'finance':
return 'attach_money';
case 'evenements':
return 'event';
case 'solidarite':
return 'favorite';
default:
return 'analytics';
}
}
/// Retourne la couleur appropriée
String get couleur {
switch (categorie) {
case 'membres':
return '#2196F3';
case 'finance':
return '#4CAF50';
case 'evenements':
return '#FF9800';
case 'solidarite':
return '#E91E63';
default:
return '#757575';
}
}
}
/// Énumération des périodes d'analyse
enum PeriodeAnalyse {
aujourdHui('Aujourd\'hui', 'today'),
hier('Hier', 'yesterday'),
cetteSemaine('Cette semaine', 'this_week'),
semaineDerniere('Semaine dernière', 'last_week'),
ceMois('Ce mois', 'this_month'),
moisDernier('Mois dernier', 'last_month'),
troisDerniersMois('3 derniers mois', 'last_3_months'),
sixDerniersMois('6 derniers mois', 'last_6_months'),
cetteAnnee('Cette année', 'this_year'),
anneeDerniere('Année dernière', 'last_year'),
septDerniersJours('7 derniers jours', 'last_7_days'),
trenteDerniersJours('30 derniers jours', 'last_30_days'),
periodePersonnalisee('Période personnalisée', 'custom');
const PeriodeAnalyse(this.libelle, this.code);
final String libelle;
final String code;
/// Vérifie si la période est courte (moins d'un mois)
bool get isPeriodeCourte {
return [
aujourdHui,
hier,
cetteSemaine,
semaineDerniere,
septDerniersJours
].contains(this);
}
/// Vérifie si la période est longue (plus d'un an)
bool get isPeriodeLongue {
return [cetteAnnee, anneeDerniere].contains(this);
}
}
/// Entité représentant une donnée analytics
class AnalyticsData extends Equatable {
const AnalyticsData({
required this.id,
required this.typeMetrique,
required this.periodeAnalyse,
required this.valeur,
this.valeurPrecedente,
this.pourcentageEvolution,
required this.dateDebut,
required this.dateFin,
required this.dateCalcul,
this.organisationId,
this.nomOrganisation,
this.utilisateurId,
this.nomUtilisateur,
this.libellePersonnalise,
this.description,
this.donneesDetaillees,
this.configurationGraphique,
this.metadonnees,
this.indicateurFiabilite = 95.0,
this.nombreElementsAnalyses,
this.tempsCalculMs,
this.tempsReel = false,
this.necessiteMiseAJour = false,
this.niveauPriorite = 3,
this.tags,
});
final String id;
final TypeMetrique typeMetrique;
final PeriodeAnalyse periodeAnalyse;
final double valeur;
final double? valeurPrecedente;
final double? pourcentageEvolution;
final DateTime dateDebut;
final DateTime dateFin;
final DateTime dateCalcul;
final String? organisationId;
final String? nomOrganisation;
final String? utilisateurId;
final String? nomUtilisateur;
final String? libellePersonnalise;
final String? description;
final String? donneesDetaillees;
final String? configurationGraphique;
final Map<String, dynamic>? metadonnees;
final double indicateurFiabilite;
final int? nombreElementsAnalyses;
final int? tempsCalculMs;
final bool tempsReel;
final bool necessiteMiseAJour;
final int niveauPriorite;
final List<String>? tags;
/// Retourne le libellé à afficher
String get libelleAffichage {
return libellePersonnalise?.isNotEmpty == true
? libellePersonnalise!
: typeMetrique.libelle;
}
/// Retourne l'unité de mesure
String get unite => typeMetrique.unite;
/// Retourne l'icône
String get icone => typeMetrique.icone;
/// Retourne la couleur
String get couleur => typeMetrique.couleur;
/// Vérifie si la métrique a évolué positivement
bool get hasEvolutionPositive {
return pourcentageEvolution != null && pourcentageEvolution! > 0;
}
/// Vérifie si la métrique a évolué négativement
bool get hasEvolutionNegative {
return pourcentageEvolution != null && pourcentageEvolution! < 0;
}
/// Vérifie si la métrique est stable
bool get isStable {
return pourcentageEvolution != null && pourcentageEvolution! == 0;
}
/// Retourne la tendance sous forme de texte
String get tendance {
if (hasEvolutionPositive) return 'hausse';
if (hasEvolutionNegative) return 'baisse';
return 'stable';
}
/// Vérifie si les données sont fiables
bool get isDonneesFiables => indicateurFiabilite >= 80.0;
/// Vérifie si la métrique est critique
bool get isCritique => niveauPriorite >= 4;
/// Formate la valeur avec l'unité appropriée
String get valeurFormatee {
switch (typeMetrique.typeValeur) {
case 'amount':
return '${valeur.toStringAsFixed(0)} ${unite}';
case 'percentage':
return '${valeur.toStringAsFixed(1)}${unite}';
case 'average':
return valeur.toStringAsFixed(1);
default:
return valeur.toStringAsFixed(0);
}
}
/// Formate le pourcentage d'évolution
String get evolutionFormatee {
if (pourcentageEvolution == null) return '';
final signe = pourcentageEvolution! >= 0 ? '+' : '';
return '$signe${pourcentageEvolution!.toStringAsFixed(1)}%';
}
@override
List<Object?> get props => [
id,
typeMetrique,
periodeAnalyse,
valeur,
valeurPrecedente,
pourcentageEvolution,
dateDebut,
dateFin,
dateCalcul,
organisationId,
nomOrganisation,
utilisateurId,
nomUtilisateur,
libellePersonnalise,
description,
donneesDetaillees,
configurationGraphique,
metadonnees,
indicateurFiabilite,
nombreElementsAnalyses,
tempsCalculMs,
tempsReel,
necessiteMiseAJour,
niveauPriorite,
tags,
];
AnalyticsData copyWith({
String? id,
TypeMetrique? typeMetrique,
PeriodeAnalyse? periodeAnalyse,
double? valeur,
double? valeurPrecedente,
double? pourcentageEvolution,
DateTime? dateDebut,
DateTime? dateFin,
DateTime? dateCalcul,
String? organisationId,
String? nomOrganisation,
String? utilisateurId,
String? nomUtilisateur,
String? libellePersonnalise,
String? description,
String? donneesDetaillees,
String? configurationGraphique,
Map<String, dynamic>? metadonnees,
double? indicateurFiabilite,
int? nombreElementsAnalyses,
int? tempsCalculMs,
bool? tempsReel,
bool? necessiteMiseAJour,
int? niveauPriorite,
List<String>? tags,
}) {
return AnalyticsData(
id: id ?? this.id,
typeMetrique: typeMetrique ?? this.typeMetrique,
periodeAnalyse: periodeAnalyse ?? this.periodeAnalyse,
valeur: valeur ?? this.valeur,
valeurPrecedente: valeurPrecedente ?? this.valeurPrecedente,
pourcentageEvolution: pourcentageEvolution ?? this.pourcentageEvolution,
dateDebut: dateDebut ?? this.dateDebut,
dateFin: dateFin ?? this.dateFin,
dateCalcul: dateCalcul ?? this.dateCalcul,
organisationId: organisationId ?? this.organisationId,
nomOrganisation: nomOrganisation ?? this.nomOrganisation,
utilisateurId: utilisateurId ?? this.utilisateurId,
nomUtilisateur: nomUtilisateur ?? this.nomUtilisateur,
libellePersonnalise: libellePersonnalise ?? this.libellePersonnalise,
description: description ?? this.description,
donneesDetaillees: donneesDetaillees ?? this.donneesDetaillees,
configurationGraphique: configurationGraphique ?? this.configurationGraphique,
metadonnees: metadonnees ?? this.metadonnees,
indicateurFiabilite: indicateurFiabilite ?? this.indicateurFiabilite,
nombreElementsAnalyses: nombreElementsAnalyses ?? this.nombreElementsAnalyses,
tempsCalculMs: tempsCalculMs ?? this.tempsCalculMs,
tempsReel: tempsReel ?? this.tempsReel,
necessiteMiseAJour: necessiteMiseAJour ?? this.necessiteMiseAJour,
niveauPriorite: niveauPriorite ?? this.niveauPriorite,
tags: tags ?? this.tags,
);
}
}

View File

@@ -0,0 +1,351 @@
import 'package:equatable/equatable.dart';
import 'analytics_data.dart';
/// Point de données pour une tendance KPI
class PointDonnee extends Equatable {
const PointDonnee({
required this.date,
required this.valeur,
this.libelle,
this.anomalie = false,
this.prediction = false,
this.metadonnees,
});
final DateTime date;
final double valeur;
final String? libelle;
final bool anomalie;
final bool prediction;
final String? metadonnees;
@override
List<Object?> get props => [
date,
valeur,
libelle,
anomalie,
prediction,
metadonnees,
];
PointDonnee copyWith({
DateTime? date,
double? valeur,
String? libelle,
bool? anomalie,
bool? prediction,
String? metadonnees,
}) {
return PointDonnee(
date: date ?? this.date,
valeur: valeur ?? this.valeur,
libelle: libelle ?? this.libelle,
anomalie: anomalie ?? this.anomalie,
prediction: prediction ?? this.prediction,
metadonnees: metadonnees ?? this.metadonnees,
);
}
}
/// Entité représentant les tendances et évolutions d'un KPI
class KPITrend extends Equatable {
const KPITrend({
required this.id,
required this.typeMetrique,
required this.periodeAnalyse,
this.organisationId,
this.nomOrganisation,
required this.dateDebut,
required this.dateFin,
required this.pointsDonnees,
required this.valeurActuelle,
this.valeurMinimale,
this.valeurMaximale,
this.valeurMoyenne,
this.ecartType,
this.coefficientVariation,
this.tendanceGenerale,
this.coefficientCorrelation,
this.pourcentageEvolutionGlobale,
this.predictionProchainePeriode,
this.margeErreurPrediction,
this.seuilAlerteBas,
this.seuilAlerteHaut,
this.alerteActive = false,
this.typeAlerte,
this.messageAlerte,
this.configurationGraphique,
this.intervalleRegroupement,
this.formatDate,
this.dateDerniereMiseAJour,
this.frequenceMiseAJourMinutes,
});
final String id;
final TypeMetrique typeMetrique;
final PeriodeAnalyse periodeAnalyse;
final String? organisationId;
final String? nomOrganisation;
final DateTime dateDebut;
final DateTime dateFin;
final List<PointDonnee> pointsDonnees;
final double valeurActuelle;
final double? valeurMinimale;
final double? valeurMaximale;
final double? valeurMoyenne;
final double? ecartType;
final double? coefficientVariation;
final double? tendanceGenerale;
final double? coefficientCorrelation;
final double? pourcentageEvolutionGlobale;
final double? predictionProchainePeriode;
final double? margeErreurPrediction;
final double? seuilAlerteBas;
final double? seuilAlerteHaut;
final bool alerteActive;
final String? typeAlerte;
final String? messageAlerte;
final String? configurationGraphique;
final String? intervalleRegroupement;
final String? formatDate;
final DateTime? dateDerniereMiseAJour;
final int? frequenceMiseAJourMinutes;
/// Retourne le libellé de la métrique
String get libelleMetrique => typeMetrique.libelle;
/// Retourne l'unité de mesure
String get unite => typeMetrique.unite;
/// Retourne l'icône de la métrique
String get icone => typeMetrique.icone;
/// Retourne la couleur de la métrique
String get couleur => typeMetrique.couleur;
/// Vérifie si la tendance est positive
bool get isTendancePositive {
return tendanceGenerale != null && tendanceGenerale! > 0;
}
/// Vérifie si la tendance est négative
bool get isTendanceNegative {
return tendanceGenerale != null && tendanceGenerale! < 0;
}
/// Vérifie si la tendance est stable
bool get isTendanceStable {
return tendanceGenerale != null && tendanceGenerale! == 0;
}
/// Retourne la volatilité du KPI
String get volatilite {
if (coefficientVariation == null) return 'inconnue';
if (coefficientVariation! <= 0.1) return 'faible';
if (coefficientVariation! <= 0.3) return 'moyenne';
return 'élevée';
}
/// Vérifie si la prédiction est fiable
bool get isPredictionFiable {
return coefficientCorrelation != null && coefficientCorrelation! >= 0.7;
}
/// Retourne le nombre de points de données
int get nombrePointsDonnees => pointsDonnees.length;
/// Vérifie si des anomalies ont été détectées
bool get hasAnomalies {
return pointsDonnees.any((point) => point.anomalie);
}
/// Retourne les points d'anomalies
List<PointDonnee> get pointsAnomalies {
return pointsDonnees.where((point) => point.anomalie).toList();
}
/// Retourne les points de prédiction
List<PointDonnee> get pointsPredictions {
return pointsDonnees.where((point) => point.prediction).toList();
}
/// Formate la valeur actuelle
String get valeurActuelleFormatee {
switch (typeMetrique.typeValeur) {
case 'amount':
return '${valeurActuelle.toStringAsFixed(0)} ${unite}';
case 'percentage':
return '${valeurActuelle.toStringAsFixed(1)}${unite}';
case 'average':
return valeurActuelle.toStringAsFixed(1);
default:
return valeurActuelle.toStringAsFixed(0);
}
}
/// Formate l'évolution globale
String get evolutionGlobaleFormatee {
if (pourcentageEvolutionGlobale == null) return '';
final signe = pourcentageEvolutionGlobale! >= 0 ? '+' : '';
return '$signe${pourcentageEvolutionGlobale!.toStringAsFixed(1)}%';
}
/// Formate la prédiction
String get predictionFormatee {
if (predictionProchainePeriode == null) return '';
switch (typeMetrique.typeValeur) {
case 'amount':
return '${predictionProchainePeriode!.toStringAsFixed(0)} ${unite}';
case 'percentage':
return '${predictionProchainePeriode!.toStringAsFixed(1)}${unite}';
case 'average':
return predictionProchainePeriode!.toStringAsFixed(1);
default:
return predictionProchainePeriode!.toStringAsFixed(0);
}
}
/// Retourne la description de la tendance
String get descriptionTendance {
if (isTendancePositive) {
return 'Tendance à la hausse';
} else if (isTendanceNegative) {
return 'Tendance à la baisse';
} else {
return 'Tendance stable';
}
}
/// Retourne l'icône de la tendance
String get iconeTendance {
if (isTendancePositive) {
return 'trending_up';
} else if (isTendanceNegative) {
return 'trending_down';
} else {
return 'trending_flat';
}
}
/// Retourne la couleur de la tendance
String get couleurTendance {
if (isTendancePositive) {
return '#4CAF50'; // Vert
} else if (isTendanceNegative) {
return '#F44336'; // Rouge
} else {
return '#FF9800'; // Orange
}
}
/// Retourne le niveau de confiance de la prédiction
String get niveauConfiancePrediction {
if (coefficientCorrelation == null) return 'Inconnu';
if (coefficientCorrelation! >= 0.9) return 'Très élevé';
if (coefficientCorrelation! >= 0.7) return 'Élevé';
if (coefficientCorrelation! >= 0.5) return 'Moyen';
if (coefficientCorrelation! >= 0.3) return 'Faible';
return 'Très faible';
}
@override
List<Object?> get props => [
id,
typeMetrique,
periodeAnalyse,
organisationId,
nomOrganisation,
dateDebut,
dateFin,
pointsDonnees,
valeurActuelle,
valeurMinimale,
valeurMaximale,
valeurMoyenne,
ecartType,
coefficientVariation,
tendanceGenerale,
coefficientCorrelation,
pourcentageEvolutionGlobale,
predictionProchainePeriode,
margeErreurPrediction,
seuilAlerteBas,
seuilAlerteHaut,
alerteActive,
typeAlerte,
messageAlerte,
configurationGraphique,
intervalleRegroupement,
formatDate,
dateDerniereMiseAJour,
frequenceMiseAJourMinutes,
];
KPITrend copyWith({
String? id,
TypeMetrique? typeMetrique,
PeriodeAnalyse? periodeAnalyse,
String? organisationId,
String? nomOrganisation,
DateTime? dateDebut,
DateTime? dateFin,
List<PointDonnee>? pointsDonnees,
double? valeurActuelle,
double? valeurMinimale,
double? valeurMaximale,
double? valeurMoyenne,
double? ecartType,
double? coefficientVariation,
double? tendanceGenerale,
double? coefficientCorrelation,
double? pourcentageEvolutionGlobale,
double? predictionProchainePeriode,
double? margeErreurPrediction,
double? seuilAlerteBas,
double? seuilAlerteHaut,
bool? alerteActive,
String? typeAlerte,
String? messageAlerte,
String? configurationGraphique,
String? intervalleRegroupement,
String? formatDate,
DateTime? dateDerniereMiseAJour,
int? frequenceMiseAJourMinutes,
}) {
return KPITrend(
id: id ?? this.id,
typeMetrique: typeMetrique ?? this.typeMetrique,
periodeAnalyse: periodeAnalyse ?? this.periodeAnalyse,
organisationId: organisationId ?? this.organisationId,
nomOrganisation: nomOrganisation ?? this.nomOrganisation,
dateDebut: dateDebut ?? this.dateDebut,
dateFin: dateFin ?? this.dateFin,
pointsDonnees: pointsDonnees ?? this.pointsDonnees,
valeurActuelle: valeurActuelle ?? this.valeurActuelle,
valeurMinimale: valeurMinimale ?? this.valeurMinimale,
valeurMaximale: valeurMaximale ?? this.valeurMaximale,
valeurMoyenne: valeurMoyenne ?? this.valeurMoyenne,
ecartType: ecartType ?? this.ecartType,
coefficientVariation: coefficientVariation ?? this.coefficientVariation,
tendanceGenerale: tendanceGenerale ?? this.tendanceGenerale,
coefficientCorrelation: coefficientCorrelation ?? this.coefficientCorrelation,
pourcentageEvolutionGlobale: pourcentageEvolutionGlobale ?? this.pourcentageEvolutionGlobale,
predictionProchainePeriode: predictionProchainePeriode ?? this.predictionProchainePeriode,
margeErreurPrediction: margeErreurPrediction ?? this.margeErreurPrediction,
seuilAlerteBas: seuilAlerteBas ?? this.seuilAlerteBas,
seuilAlerteHaut: seuilAlerteHaut ?? this.seuilAlerteHaut,
alerteActive: alerteActive ?? this.alerteActive,
typeAlerte: typeAlerte ?? this.typeAlerte,
messageAlerte: messageAlerte ?? this.messageAlerte,
configurationGraphique: configurationGraphique ?? this.configurationGraphique,
intervalleRegroupement: intervalleRegroupement ?? this.intervalleRegroupement,
formatDate: formatDate ?? this.formatDate,
dateDerniereMiseAJour: dateDerniereMiseAJour ?? this.dateDerniereMiseAJour,
frequenceMiseAJourMinutes: frequenceMiseAJourMinutes ?? this.frequenceMiseAJourMinutes,
);
}
}

View File

@@ -0,0 +1,139 @@
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/analytics_data.dart';
import '../entities/kpi_trend.dart';
/// Repository abstrait pour les analytics
abstract class AnalyticsRepository {
/// Calcule une métrique analytics pour une période donnée
Future<Either<Failure, AnalyticsData>> calculerMetrique({
required TypeMetrique typeMetrique,
required PeriodeAnalyse periodeAnalyse,
String? organisationId,
});
/// Calcule les tendances d'un KPI sur une période
Future<Either<Failure, KPITrend>> calculerTendanceKPI({
required TypeMetrique typeMetrique,
required PeriodeAnalyse periodeAnalyse,
String? organisationId,
});
/// Obtient tous les KPI pour une organisation
Future<Either<Failure, Map<TypeMetrique, double>>> obtenirTousLesKPI({
required PeriodeAnalyse periodeAnalyse,
String? organisationId,
});
/// Calcule le KPI de performance globale
Future<Either<Failure, double>> calculerPerformanceGlobale({
required PeriodeAnalyse periodeAnalyse,
String? organisationId,
});
/// Obtient les évolutions des KPI par rapport à la période précédente
Future<Either<Failure, Map<TypeMetrique, double>>> obtenirEvolutionsKPI({
required PeriodeAnalyse periodeAnalyse,
String? organisationId,
});
/// Obtient les métriques pour le tableau de bord
Future<Either<Failure, List<AnalyticsData>>> obtenirMetriquesTableauBord({
String? organisationId,
required String utilisateurId,
});
/// Obtient les types de métriques disponibles
Future<Either<Failure, List<TypeMetrique>>> obtenirTypesMetriques();
/// Obtient les périodes d'analyse disponibles
Future<Either<Failure, List<PeriodeAnalyse>>> obtenirPeriodesAnalyse();
/// Met en cache les données analytics
Future<Either<Failure, void>> mettreEnCache({
required String cle,
required Map<String, dynamic> donnees,
Duration? dureeVie,
});
/// Récupère les données depuis le cache
Future<Either<Failure, Map<String, dynamic>?>> recupererDepuisCache({
required String cle,
});
/// Vide le cache analytics
Future<Either<Failure, void>> viderCache();
/// Synchronise les données analytics avec le serveur
Future<Either<Failure, void>> synchroniserDonnees();
/// Vérifie si les données sont à jour
Future<Either<Failure, bool>> verifierMiseAJour({
required TypeMetrique typeMetrique,
required PeriodeAnalyse periodeAnalyse,
String? organisationId,
});
/// Obtient les alertes actives
Future<Either<Failure, List<AnalyticsData>>> obtenirAlertesActives({
String? organisationId,
});
/// Marque une alerte comme lue
Future<Either<Failure, void>> marquerAlerteLue({
required String alerteId,
});
/// Exporte les données analytics
Future<Either<Failure, String>> exporterDonnees({
required List<TypeMetrique> metriques,
required PeriodeAnalyse periodeAnalyse,
String? organisationId,
required String format, // 'json', 'csv', 'excel'
});
/// Obtient l'historique des calculs
Future<Either<Failure, List<AnalyticsData>>> obtenirHistoriqueCalculs({
required TypeMetrique typeMetrique,
String? organisationId,
int limite = 50,
});
/// Sauvegarde une configuration de rapport personnalisé
Future<Either<Failure, void>> sauvegarderConfigurationRapport({
required String nom,
required List<TypeMetrique> metriques,
required PeriodeAnalyse periodeAnalyse,
String? organisationId,
Map<String, dynamic>? configuration,
});
/// Obtient les configurations de rapports sauvegardées
Future<Either<Failure, List<Map<String, dynamic>>>> obtenirConfigurationsRapports({
String? organisationId,
});
/// Supprime une configuration de rapport
Future<Either<Failure, void>> supprimerConfigurationRapport({
required String configurationId,
});
/// Planifie une mise à jour automatique
Future<Either<Failure, void>> planifierMiseAJourAutomatique({
required TypeMetrique typeMetrique,
required PeriodeAnalyse periodeAnalyse,
String? organisationId,
required Duration frequence,
});
/// Annule une mise à jour automatique planifiée
Future<Either<Failure, void>> annulerMiseAJourAutomatique({
required String planificationId,
});
/// Obtient les statistiques d'utilisation des analytics
Future<Either<Failure, Map<String, dynamic>>> obtenirStatistiquesUtilisation({
String? organisationId,
String? utilisateurId,
});
}

View File

@@ -0,0 +1,207 @@
import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/analytics_data.dart';
import '../repositories/analytics_repository.dart';
/// Use case pour calculer une métrique analytics
class CalculerMetriqueUseCase implements UseCase<AnalyticsData, CalculerMetriqueParams> {
const CalculerMetriqueUseCase(this.repository);
final AnalyticsRepository repository;
@override
Future<Either<Failure, AnalyticsData>> call(CalculerMetriqueParams params) async {
// Vérifier d'abord le cache
final cacheKey = _genererCleCacheMetrique(params);
final cacheResult = await repository.recupererDepuisCache(cle: cacheKey);
return cacheResult.fold(
(failure) => _calculerEtCacherMetrique(params, cacheKey),
(cachedData) {
if (cachedData != null && _isCacheValide(cachedData)) {
// Retourner les données du cache si elles sont valides
return Right(_mapCacheToAnalyticsData(cachedData));
} else {
// Recalculer si le cache est expiré ou invalide
return _calculerEtCacherMetrique(params, cacheKey);
}
},
);
}
/// Calcule la métrique et la met en cache
Future<Either<Failure, AnalyticsData>> _calculerEtCacherMetrique(
CalculerMetriqueParams params,
String cacheKey,
) async {
final result = await repository.calculerMetrique(
typeMetrique: params.typeMetrique,
periodeAnalyse: params.periodeAnalyse,
organisationId: params.organisationId,
);
return result.fold(
(failure) => Left(failure),
(analyticsData) async {
// Mettre en cache le résultat
await repository.mettreEnCache(
cle: cacheKey,
donnees: _mapAnalyticsDataToCache(analyticsData),
dureeVie: _determinerDureeVieCache(params.periodeAnalyse),
);
return Right(analyticsData);
},
);
}
/// Génère une clé de cache unique pour la métrique
String _genererCleCacheMetrique(CalculerMetriqueParams params) {
return 'metrique_${params.typeMetrique.name}_${params.periodeAnalyse.name}_${params.organisationId ?? 'global'}';
}
/// Vérifie si les données du cache sont encore valides
bool _isCacheValide(Map<String, dynamic> cachedData) {
final dateCache = DateTime.tryParse(cachedData['dateCache'] ?? '');
if (dateCache == null) return false;
final dureeVie = Duration(minutes: cachedData['dureeVieMinutes'] ?? 60);
return DateTime.now().difference(dateCache) < dureeVie;
}
/// Convertit les données analytics en format cache
Map<String, dynamic> _mapAnalyticsDataToCache(AnalyticsData data) {
return {
'id': data.id,
'typeMetrique': data.typeMetrique.name,
'periodeAnalyse': data.periodeAnalyse.name,
'valeur': data.valeur,
'valeurPrecedente': data.valeurPrecedente,
'pourcentageEvolution': data.pourcentageEvolution,
'dateDebut': data.dateDebut.toIso8601String(),
'dateFin': data.dateFin.toIso8601String(),
'dateCalcul': data.dateCalcul.toIso8601String(),
'organisationId': data.organisationId,
'nomOrganisation': data.nomOrganisation,
'utilisateurId': data.utilisateurId,
'nomUtilisateur': data.nomUtilisateur,
'libellePersonnalise': data.libellePersonnalise,
'description': data.description,
'donneesDetaillees': data.donneesDetaillees,
'configurationGraphique': data.configurationGraphique,
'metadonnees': data.metadonnees,
'indicateurFiabilite': data.indicateurFiabilite,
'nombreElementsAnalyses': data.nombreElementsAnalyses,
'tempsCalculMs': data.tempsCalculMs,
'tempsReel': data.tempsReel,
'necessiteMiseAJour': data.necessiteMiseAJour,
'niveauPriorite': data.niveauPriorite,
'tags': data.tags,
'dateCache': DateTime.now().toIso8601String(),
'dureeVieMinutes': _determinerDureeVieCache(data.periodeAnalyse).inMinutes,
};
}
/// Convertit les données du cache en AnalyticsData
AnalyticsData _mapCacheToAnalyticsData(Map<String, dynamic> cachedData) {
return AnalyticsData(
id: cachedData['id'],
typeMetrique: TypeMetrique.values.firstWhere(
(e) => e.name == cachedData['typeMetrique'],
),
periodeAnalyse: PeriodeAnalyse.values.firstWhere(
(e) => e.name == cachedData['periodeAnalyse'],
),
valeur: cachedData['valeur']?.toDouble() ?? 0.0,
valeurPrecedente: cachedData['valeurPrecedente']?.toDouble(),
pourcentageEvolution: cachedData['pourcentageEvolution']?.toDouble(),
dateDebut: DateTime.parse(cachedData['dateDebut']),
dateFin: DateTime.parse(cachedData['dateFin']),
dateCalcul: DateTime.parse(cachedData['dateCalcul']),
organisationId: cachedData['organisationId'],
nomOrganisation: cachedData['nomOrganisation'],
utilisateurId: cachedData['utilisateurId'],
nomUtilisateur: cachedData['nomUtilisateur'],
libellePersonnalise: cachedData['libellePersonnalise'],
description: cachedData['description'],
donneesDetaillees: cachedData['donneesDetaillees'],
configurationGraphique: cachedData['configurationGraphique'],
metadonnees: cachedData['metadonnees'] != null
? Map<String, dynamic>.from(cachedData['metadonnees'])
: null,
indicateurFiabilite: cachedData['indicateurFiabilite']?.toDouble() ?? 95.0,
nombreElementsAnalyses: cachedData['nombreElementsAnalyses'],
tempsCalculMs: cachedData['tempsCalculMs'],
tempsReel: cachedData['tempsReel'] ?? false,
necessiteMiseAJour: cachedData['necessiteMiseAJour'] ?? false,
niveauPriorite: cachedData['niveauPriorite'] ?? 3,
tags: cachedData['tags'] != null
? List<String>.from(cachedData['tags'])
: null,
);
}
/// Détermine la durée de vie du cache selon la période
Duration _determinerDureeVieCache(PeriodeAnalyse periode) {
switch (periode) {
case PeriodeAnalyse.aujourdHui:
case PeriodeAnalyse.hier:
return const Duration(minutes: 15); // 15 minutes pour les données récentes
case PeriodeAnalyse.cetteSemaine:
case PeriodeAnalyse.semaineDerniere:
case PeriodeAnalyse.septDerniersJours:
return const Duration(hours: 1); // 1 heure pour les données hebdomadaires
case PeriodeAnalyse.ceMois:
case PeriodeAnalyse.moisDernier:
case PeriodeAnalyse.trenteDerniersJours:
return const Duration(hours: 4); // 4 heures pour les données mensuelles
case PeriodeAnalyse.troisDerniersMois:
case PeriodeAnalyse.sixDerniersMois:
return const Duration(hours: 12); // 12 heures pour les données trimestrielles
case PeriodeAnalyse.cetteAnnee:
case PeriodeAnalyse.anneeDerniere:
return const Duration(days: 1); // 1 jour pour les données annuelles
case PeriodeAnalyse.periodePersonnalisee:
return const Duration(hours: 2); // 2 heures par défaut
}
}
}
/// Paramètres pour le use case CalculerMetrique
class CalculerMetriqueParams extends Equatable {
const CalculerMetriqueParams({
required this.typeMetrique,
required this.periodeAnalyse,
this.organisationId,
this.forceRecalcul = false,
});
final TypeMetrique typeMetrique;
final PeriodeAnalyse periodeAnalyse;
final String? organisationId;
final bool forceRecalcul;
@override
List<Object?> get props => [
typeMetrique,
periodeAnalyse,
organisationId,
forceRecalcul,
];
CalculerMetriqueParams copyWith({
TypeMetrique? typeMetrique,
PeriodeAnalyse? periodeAnalyse,
String? organisationId,
bool? forceRecalcul,
}) {
return CalculerMetriqueParams(
typeMetrique: typeMetrique ?? this.typeMetrique,
periodeAnalyse: periodeAnalyse ?? this.periodeAnalyse,
organisationId: organisationId ?? this.organisationId,
forceRecalcul: forceRecalcul ?? this.forceRecalcul,
);
}
}

View File

@@ -0,0 +1,249 @@
import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/analytics_data.dart';
import '../entities/kpi_trend.dart';
import '../repositories/analytics_repository.dart';
/// Use case pour calculer les tendances d'un KPI
class CalculerTendanceKPIUseCase implements UseCase<KPITrend, CalculerTendanceKPIParams> {
const CalculerTendanceKPIUseCase(this.repository);
final AnalyticsRepository repository;
@override
Future<Either<Failure, KPITrend>> call(CalculerTendanceKPIParams params) async {
// Vérifier d'abord le cache si pas de recalcul forcé
if (!params.forceRecalcul) {
final cacheKey = _genererCleCacheTendance(params);
final cacheResult = await repository.recupererDepuisCache(cle: cacheKey);
final cachedTrend = await cacheResult.fold(
(failure) => null,
(cachedData) {
if (cachedData != null && _isCacheValide(cachedData)) {
return _mapCacheToKPITrend(cachedData);
}
return null;
},
);
if (cachedTrend != null) {
return Right(cachedTrend);
}
}
// Calculer la tendance depuis le serveur
return _calculerEtCacherTendance(params);
}
/// Calcule la tendance et la met en cache
Future<Either<Failure, KPITrend>> _calculerEtCacherTendance(
CalculerTendanceKPIParams params,
) async {
final result = await repository.calculerTendanceKPI(
typeMetrique: params.typeMetrique,
periodeAnalyse: params.periodeAnalyse,
organisationId: params.organisationId,
);
return result.fold(
(failure) => Left(failure),
(kpiTrend) async {
// Mettre en cache le résultat
final cacheKey = _genererCleCacheTendance(params);
await repository.mettreEnCache(
cle: cacheKey,
donnees: _mapKPITrendToCache(kpiTrend),
dureeVie: _determinerDureeVieCache(params.periodeAnalyse),
);
return Right(kpiTrend);
},
);
}
/// Génère une clé de cache unique pour la tendance
String _genererCleCacheTendance(CalculerTendanceKPIParams params) {
return 'tendance_${params.typeMetrique.name}_${params.periodeAnalyse.name}_${params.organisationId ?? 'global'}';
}
/// Vérifie si les données du cache sont encore valides
bool _isCacheValide(Map<String, dynamic> cachedData) {
final dateCache = DateTime.tryParse(cachedData['dateCache'] ?? '');
if (dateCache == null) return false;
final dureeVie = Duration(minutes: cachedData['dureeVieMinutes'] ?? 120);
return DateTime.now().difference(dateCache) < dureeVie;
}
/// Convertit KPITrend en format cache
Map<String, dynamic> _mapKPITrendToCache(KPITrend trend) {
return {
'id': trend.id,
'typeMetrique': trend.typeMetrique.name,
'periodeAnalyse': trend.periodeAnalyse.name,
'organisationId': trend.organisationId,
'nomOrganisation': trend.nomOrganisation,
'dateDebut': trend.dateDebut.toIso8601String(),
'dateFin': trend.dateFin.toIso8601String(),
'pointsDonnees': trend.pointsDonnees.map((point) => {
'date': point.date.toIso8601String(),
'valeur': point.valeur,
'libelle': point.libelle,
'anomalie': point.anomalie,
'prediction': point.prediction,
'metadonnees': point.metadonnees,
}).toList(),
'valeurActuelle': trend.valeurActuelle,
'valeurMinimale': trend.valeurMinimale,
'valeurMaximale': trend.valeurMaximale,
'valeurMoyenne': trend.valeurMoyenne,
'ecartType': trend.ecartType,
'coefficientVariation': trend.coefficientVariation,
'tendanceGenerale': trend.tendanceGenerale,
'coefficientCorrelation': trend.coefficientCorrelation,
'pourcentageEvolutionGlobale': trend.pourcentageEvolutionGlobale,
'predictionProchainePeriode': trend.predictionProchainePeriode,
'margeErreurPrediction': trend.margeErreurPrediction,
'seuilAlerteBas': trend.seuilAlerteBas,
'seuilAlerteHaut': trend.seuilAlerteHaut,
'alerteActive': trend.alerteActive,
'typeAlerte': trend.typeAlerte,
'messageAlerte': trend.messageAlerte,
'configurationGraphique': trend.configurationGraphique,
'intervalleRegroupement': trend.intervalleRegroupement,
'formatDate': trend.formatDate,
'dateDerniereMiseAJour': trend.dateDerniereMiseAJour?.toIso8601String(),
'frequenceMiseAJourMinutes': trend.frequenceMiseAJourMinutes,
'dateCache': DateTime.now().toIso8601String(),
'dureeVieMinutes': _determinerDureeVieCache(trend.periodeAnalyse).inMinutes,
};
}
/// Convertit les données du cache en KPITrend
KPITrend _mapCacheToKPITrend(Map<String, dynamic> cachedData) {
final pointsDonneesList = cachedData['pointsDonnees'] as List<dynamic>? ?? [];
final pointsDonnees = pointsDonneesList.map((pointData) {
return PointDonnee(
date: DateTime.parse(pointData['date']),
valeur: pointData['valeur']?.toDouble() ?? 0.0,
libelle: pointData['libelle'],
anomalie: pointData['anomalie'] ?? false,
prediction: pointData['prediction'] ?? false,
metadonnees: pointData['metadonnees'],
);
}).toList();
return KPITrend(
id: cachedData['id'],
typeMetrique: TypeMetrique.values.firstWhere(
(e) => e.name == cachedData['typeMetrique'],
),
periodeAnalyse: PeriodeAnalyse.values.firstWhere(
(e) => e.name == cachedData['periodeAnalyse'],
),
organisationId: cachedData['organisationId'],
nomOrganisation: cachedData['nomOrganisation'],
dateDebut: DateTime.parse(cachedData['dateDebut']),
dateFin: DateTime.parse(cachedData['dateFin']),
pointsDonnees: pointsDonnees,
valeurActuelle: cachedData['valeurActuelle']?.toDouble() ?? 0.0,
valeurMinimale: cachedData['valeurMinimale']?.toDouble(),
valeurMaximale: cachedData['valeurMaximale']?.toDouble(),
valeurMoyenne: cachedData['valeurMoyenne']?.toDouble(),
ecartType: cachedData['ecartType']?.toDouble(),
coefficientVariation: cachedData['coefficientVariation']?.toDouble(),
tendanceGenerale: cachedData['tendanceGenerale']?.toDouble(),
coefficientCorrelation: cachedData['coefficientCorrelation']?.toDouble(),
pourcentageEvolutionGlobale: cachedData['pourcentageEvolutionGlobale']?.toDouble(),
predictionProchainePeriode: cachedData['predictionProchainePeriode']?.toDouble(),
margeErreurPrediction: cachedData['margeErreurPrediction']?.toDouble(),
seuilAlerteBas: cachedData['seuilAlerteBas']?.toDouble(),
seuilAlerteHaut: cachedData['seuilAlerteHaut']?.toDouble(),
alerteActive: cachedData['alerteActive'] ?? false,
typeAlerte: cachedData['typeAlerte'],
messageAlerte: cachedData['messageAlerte'],
configurationGraphique: cachedData['configurationGraphique'],
intervalleRegroupement: cachedData['intervalleRegroupement'],
formatDate: cachedData['formatDate'],
dateDerniereMiseAJour: cachedData['dateDerniereMiseAJour'] != null
? DateTime.parse(cachedData['dateDerniereMiseAJour'])
: null,
frequenceMiseAJourMinutes: cachedData['frequenceMiseAJourMinutes'],
);
}
/// Détermine la durée de vie du cache selon la période
Duration _determinerDureeVieCache(PeriodeAnalyse periode) {
switch (periode) {
case PeriodeAnalyse.aujourdHui:
case PeriodeAnalyse.hier:
return const Duration(minutes: 30); // 30 minutes pour les tendances récentes
case PeriodeAnalyse.cetteSemaine:
case PeriodeAnalyse.semaineDerniere:
case PeriodeAnalyse.septDerniersJours:
return const Duration(hours: 2); // 2 heures pour les tendances hebdomadaires
case PeriodeAnalyse.ceMois:
case PeriodeAnalyse.moisDernier:
case PeriodeAnalyse.trenteDerniersJours:
return const Duration(hours: 6); // 6 heures pour les tendances mensuelles
case PeriodeAnalyse.troisDerniersMois:
case PeriodeAnalyse.sixDerniersMois:
return const Duration(hours: 24); // 24 heures pour les tendances trimestrielles
case PeriodeAnalyse.cetteAnnee:
case PeriodeAnalyse.anneeDerniere:
return const Duration(days: 2); // 2 jours pour les tendances annuelles
case PeriodeAnalyse.periodePersonnalisee:
return const Duration(hours: 4); // 4 heures par défaut
}
}
}
/// Paramètres pour le use case CalculerTendanceKPI
class CalculerTendanceKPIParams extends Equatable {
const CalculerTendanceKPIParams({
required this.typeMetrique,
required this.periodeAnalyse,
this.organisationId,
this.forceRecalcul = false,
this.inclureAnomalies = true,
this.inclurePredictions = true,
});
final TypeMetrique typeMetrique;
final PeriodeAnalyse periodeAnalyse;
final String? organisationId;
final bool forceRecalcul;
final bool inclureAnomalies;
final bool inclurePredictions;
@override
List<Object?> get props => [
typeMetrique,
periodeAnalyse,
organisationId,
forceRecalcul,
inclureAnomalies,
inclurePredictions,
];
CalculerTendanceKPIParams copyWith({
TypeMetrique? typeMetrique,
PeriodeAnalyse? periodeAnalyse,
String? organisationId,
bool? forceRecalcul,
bool? inclureAnomalies,
bool? inclurePredictions,
}) {
return CalculerTendanceKPIParams(
typeMetrique: typeMetrique ?? this.typeMetrique,
periodeAnalyse: periodeAnalyse ?? this.periodeAnalyse,
organisationId: organisationId ?? this.organisationId,
forceRecalcul: forceRecalcul ?? this.forceRecalcul,
inclureAnomalies: inclureAnomalies ?? this.inclureAnomalies,
inclurePredictions: inclurePredictions ?? this.inclurePredictions,
);
}
}

View File

@@ -0,0 +1,393 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../shared/widgets/common/unified_page_layout.dart';
import '../../../../shared/widgets/common/unified_card.dart';
import '../../../../shared/theme/design_system.dart';
import '../../../../core/utils/constants.dart';
import '../bloc/analytics_bloc.dart';
import '../widgets/kpi_card_widget.dart';
import '../widgets/trend_chart_widget.dart';
import '../widgets/period_selector_widget.dart';
import '../widgets/metrics_grid_widget.dart';
import '../widgets/performance_gauge_widget.dart';
import '../widgets/alerts_panel_widget.dart';
import '../../domain/entities/analytics_data.dart';
/// Page principale du tableau de bord analytics
class AnalyticsDashboardPage extends StatefulWidget {
const AnalyticsDashboardPage({super.key});
@override
State<AnalyticsDashboardPage> createState() => _AnalyticsDashboardPageState();
}
class _AnalyticsDashboardPageState extends State<AnalyticsDashboardPage>
with TickerProviderStateMixin {
late TabController _tabController;
PeriodeAnalyse _periodeSelectionnee = PeriodeAnalyse.ceMois;
String? _organisationId;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_chargerDonneesInitiales();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _chargerDonneesInitiales() {
context.read<AnalyticsBloc>().add(
ChargerTableauBordEvent(
periodeAnalyse: _periodeSelectionnee,
organisationId: _organisationId,
),
);
}
void _onPeriodeChanged(PeriodeAnalyse nouvellePeriode) {
setState(() {
_periodeSelectionnee = nouvellePeriode;
});
_chargerDonneesInitiales();
}
@override
Widget build(BuildContext context) {
return UnifiedPageLayout(
title: 'Analytics',
subtitle: 'Tableau de bord et métriques',
showBackButton: false,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _chargerDonneesInitiales,
tooltip: 'Actualiser',
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => _ouvrirParametres(context),
tooltip: 'Paramètres',
),
],
body: Column(
children: [
// Sélecteur de période
Padding(
padding: const EdgeInsets.all(DesignSystem.spacing16),
child: PeriodSelectorWidget(
periodeSelectionnee: _periodeSelectionnee,
onPeriodeChanged: _onPeriodeChanged,
),
),
// Onglets
TabBar(
controller: _tabController,
labelColor: DesignSystem.primaryColor,
unselectedLabelColor: DesignSystem.textSecondaryColor,
indicatorColor: DesignSystem.primaryColor,
tabs: const [
Tab(
icon: Icon(Icons.dashboard),
text: 'Vue d\'ensemble',
),
Tab(
icon: Icon(Icons.trending_up),
text: 'Tendances',
),
Tab(
icon: Icon(Icons.analytics),
text: 'Détails',
),
Tab(
icon: Icon(Icons.warning),
text: 'Alertes',
),
],
),
// Contenu des onglets
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildVueEnsemble(),
_buildTendances(),
_buildDetails(),
_buildAlertes(),
],
),
),
],
),
);
}
/// Vue d'ensemble avec KPI principaux
Widget _buildVueEnsemble() {
return BlocBuilder<AnalyticsBloc, AnalyticsState>(
builder: (context, state) {
if (state is AnalyticsLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state is AnalyticsError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: DesignSystem.errorColor,
),
const SizedBox(height: DesignSystem.spacing16),
Text(
'Erreur lors du chargement',
style: DesignSystem.textTheme.headlineSmall,
),
const SizedBox(height: DesignSystem.spacing8),
Text(
state.message,
style: DesignSystem.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: DesignSystem.spacing16),
ElevatedButton(
onPressed: _chargerDonneesInitiales,
child: const Text('Réessayer'),
),
],
),
);
}
if (state is AnalyticsLoaded) {
return SingleChildScrollView(
padding: const EdgeInsets.all(DesignSystem.spacing16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Performance globale
if (state.performanceGlobale != null)
UnifiedCard(
variant: UnifiedCardVariant.elevated,
child: PerformanceGaugeWidget(
score: state.performanceGlobale!,
periode: _periodeSelectionnee,
),
),
const SizedBox(height: DesignSystem.spacing16),
// KPI principaux
Text(
'Indicateurs clés',
style: DesignSystem.textTheme.headlineSmall,
),
const SizedBox(height: DesignSystem.spacing12),
MetricsGridWidget(
metriques: state.metriques,
onMetriquePressed: (metrique) => _ouvrirDetailMetrique(
context,
metrique,
),
),
const SizedBox(height: DesignSystem.spacing24),
// Graphiques de tendance rapide
Text(
'Évolutions récentes',
style: DesignSystem.textTheme.headlineSmall,
),
const SizedBox(height: DesignSystem.spacing12),
if (state.tendances.isNotEmpty)
SizedBox(
height: 200,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: state.tendances.length,
itemBuilder: (context, index) {
final tendance = state.tendances[index];
return Container(
width: 300,
margin: const EdgeInsets.only(
right: DesignSystem.spacing12,
),
child: UnifiedCard(
variant: UnifiedCardVariant.outlined,
child: TrendChartWidget(
trend: tendance,
compact: true,
),
),
);
},
),
),
],
),
);
}
return const SizedBox.shrink();
},
);
}
/// Onglet des tendances détaillées
Widget _buildTendances() {
return BlocBuilder<AnalyticsBloc, AnalyticsState>(
builder: (context, state) {
if (state is AnalyticsLoaded && state.tendances.isNotEmpty) {
return ListView.builder(
padding: const EdgeInsets.all(DesignSystem.spacing16),
itemCount: state.tendances.length,
itemBuilder: (context, index) {
final tendance = state.tendances[index];
return Padding(
padding: const EdgeInsets.only(
bottom: DesignSystem.spacing16,
),
child: UnifiedCard(
variant: UnifiedCardVariant.elevated,
child: TrendChartWidget(
trend: tendance,
compact: false,
showPredictions: true,
showAnomalies: true,
),
),
);
},
);
}
return const Center(
child: Text('Aucune tendance disponible'),
);
},
);
}
/// Onglet des détails par métrique
Widget _buildDetails() {
return BlocBuilder<AnalyticsBloc, AnalyticsState>(
builder: (context, state) {
if (state is AnalyticsLoaded) {
return ListView.builder(
padding: const EdgeInsets.all(DesignSystem.spacing16),
itemCount: TypeMetrique.values.length,
itemBuilder: (context, index) {
final typeMetrique = TypeMetrique.values[index];
final metrique = state.metriques.firstWhere(
(m) => m.typeMetrique == typeMetrique,
orElse: () => AnalyticsData(
id: 'placeholder_$index',
typeMetrique: typeMetrique,
periodeAnalyse: _periodeSelectionnee,
valeur: 0,
dateDebut: DateTime.now().subtract(const Duration(days: 30)),
dateFin: DateTime.now(),
dateCalcul: DateTime.now(),
),
);
return Padding(
padding: const EdgeInsets.only(
bottom: DesignSystem.spacing12,
),
child: KPICardWidget(
analyticsData: metrique,
onTap: () => _ouvrirDetailMetrique(context, metrique),
showTrend: true,
showDetails: true,
),
);
},
);
}
return const Center(
child: Text('Aucun détail disponible'),
);
},
);
}
/// Onglet des alertes
Widget _buildAlertes() {
return BlocBuilder<AnalyticsBloc, AnalyticsState>(
builder: (context, state) {
if (state is AnalyticsLoaded) {
final alertes = state.metriques
.where((m) => m.isCritique || !m.isDonneesFiables)
.toList();
if (alertes.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle_outline,
size: 64,
color: DesignSystem.successColor,
),
const SizedBox(height: DesignSystem.spacing16),
Text(
'Aucune alerte active',
style: DesignSystem.textTheme.headlineSmall,
),
const SizedBox(height: DesignSystem.spacing8),
Text(
'Toutes les métriques sont dans les normes',
style: DesignSystem.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
);
}
return AlertsPanelWidget(
alertes: alertes,
onAlertePressed: (alerte) => _ouvrirDetailMetrique(
context,
alerte,
),
);
}
return const Center(
child: Text('Aucune alerte disponible'),
);
},
);
}
void _ouvrirDetailMetrique(BuildContext context, AnalyticsData metrique) {
Navigator.of(context).pushNamed(
AppRoutes.analyticsDetail,
arguments: {
'metrique': metrique,
'periode': _periodeSelectionnee,
'organisationId': _organisationId,
},
);
}
void _ouvrirParametres(BuildContext context) {
Navigator.of(context).pushNamed(AppRoutes.analyticsSettings);
}
}

View File

@@ -0,0 +1,357 @@
import 'package:flutter/material.dart';
import '../../../../shared/widgets/common/unified_card.dart';
import '../../../../shared/theme/design_system.dart';
import '../../../../core/utils/formatters.dart';
import '../../domain/entities/analytics_data.dart';
/// Widget de carte KPI utilisant le design system unifié
class KPICardWidget extends StatelessWidget {
const KPICardWidget({
super.key,
required this.analyticsData,
this.onTap,
this.showTrend = true,
this.showDetails = false,
this.compact = false,
});
final AnalyticsData analyticsData;
final VoidCallback? onTap;
final bool showTrend;
final bool showDetails;
final bool compact;
@override
Widget build(BuildContext context) {
return UnifiedCard(
variant: UnifiedCardVariant.elevated,
onTap: onTap,
child: Padding(
padding: EdgeInsets.all(
compact ? DesignSystem.spacing12 : DesignSystem.spacing16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// En-tête avec icône et titre
Row(
children: [
Container(
padding: const EdgeInsets.all(DesignSystem.spacing8),
decoration: BoxDecoration(
color: _getCouleurMetrique().withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radius8),
),
child: Icon(
_getIconeMetrique(),
color: _getCouleurMetrique(),
size: compact ? 20 : 24,
),
),
const SizedBox(width: DesignSystem.spacing12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
analyticsData.libelleAffichage,
style: compact
? DesignSystem.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
)
: DesignSystem.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (!compact && analyticsData.description != null)
Padding(
padding: const EdgeInsets.only(
top: DesignSystem.spacing4,
),
child: Text(
analyticsData.description!,
style: DesignSystem.textTheme.bodySmall?.copyWith(
color: DesignSystem.textSecondaryColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// Indicateur de fiabilité
if (showDetails)
_buildIndicateurFiabilite(),
],
),
SizedBox(height: compact ? DesignSystem.spacing8 : DesignSystem.spacing16),
// Valeur principale
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
analyticsData.valeurFormatee,
style: compact
? DesignSystem.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: _getCouleurMetrique(),
)
: DesignSystem.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: _getCouleurMetrique(),
),
),
),
// Évolution
if (showTrend && analyticsData.pourcentageEvolution != null)
_buildEvolution(),
],
),
// Détails supplémentaires
if (showDetails) ...[
const SizedBox(height: DesignSystem.spacing12),
_buildDetails(),
],
// Période et dernière mise à jour
if (!compact) ...[
const SizedBox(height: DesignSystem.spacing12),
_buildInfosPeriode(),
],
],
),
),
);
}
/// Widget d'évolution avec icône et pourcentage
Widget _buildEvolution() {
final evolution = analyticsData.pourcentageEvolution!;
final isPositive = evolution > 0;
final isNegative = evolution < 0;
Color couleur;
IconData icone;
if (isPositive) {
couleur = DesignSystem.successColor;
icone = Icons.trending_up;
} else if (isNegative) {
couleur = DesignSystem.errorColor;
icone = Icons.trending_down;
} else {
couleur = DesignSystem.warningColor;
icone = Icons.trending_flat;
}
return Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacing8,
vertical: DesignSystem.spacing4,
),
decoration: BoxDecoration(
color: couleur.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radius12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icone,
size: 16,
color: couleur,
),
const SizedBox(width: DesignSystem.spacing4),
Text(
analyticsData.evolutionFormatee,
style: DesignSystem.textTheme.bodySmall?.copyWith(
color: couleur,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
/// Widget d'indicateur de fiabilité
Widget _buildIndicateurFiabilite() {
final fiabilite = analyticsData.indicateurFiabilite;
Color couleur;
if (fiabilite >= 90) {
couleur = DesignSystem.successColor;
} else if (fiabilite >= 70) {
couleur = DesignSystem.warningColor;
} else {
couleur = DesignSystem.errorColor;
}
return Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacing6,
vertical: DesignSystem.spacing2,
),
decoration: BoxDecoration(
color: couleur.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radius8),
border: Border.all(
color: couleur.withOpacity(0.3),
width: 1,
),
),
child: Text(
'${fiabilite.toStringAsFixed(0)}%',
style: DesignSystem.textTheme.bodySmall?.copyWith(
color: couleur,
fontWeight: FontWeight.w600,
),
),
);
}
/// Widget des détails supplémentaires
Widget _buildDetails() {
return Column(
children: [
// Valeur précédente
if (analyticsData.valeurPrecedente != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Période précédente',
style: DesignSystem.textTheme.bodySmall?.copyWith(
color: DesignSystem.textSecondaryColor,
),
),
Text(
_formaterValeur(analyticsData.valeurPrecedente!),
style: DesignSystem.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: DesignSystem.spacing4),
// Éléments analysés
if (analyticsData.nombreElementsAnalyses != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Éléments analysés',
style: DesignSystem.textTheme.bodySmall?.copyWith(
color: DesignSystem.textSecondaryColor,
),
),
Text(
analyticsData.nombreElementsAnalyses.toString(),
style: DesignSystem.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: DesignSystem.spacing4),
// Temps de calcul
if (analyticsData.tempsCalculMs != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Temps de calcul',
style: DesignSystem.textTheme.bodySmall?.copyWith(
color: DesignSystem.textSecondaryColor,
),
),
Text(
'${analyticsData.tempsCalculMs}ms',
style: DesignSystem.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
],
);
}
/// Widget des informations de période
Widget _buildInfosPeriode() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
analyticsData.periodeAnalyse.libelle,
style: DesignSystem.textTheme.bodySmall?.copyWith(
color: DesignSystem.textSecondaryColor,
),
),
),
Text(
'Mis à jour ${AppFormatters.formatDateRelative(analyticsData.dateCalcul)}',
style: DesignSystem.textTheme.bodySmall?.copyWith(
color: DesignSystem.textSecondaryColor,
),
),
],
);
}
/// Obtient la couleur de la métrique
Color _getCouleurMetrique() {
return Color(int.parse(
analyticsData.couleur.replaceFirst('#', '0xFF'),
));
}
/// Obtient l'icône de la métrique
IconData _getIconeMetrique() {
switch (analyticsData.icone) {
case 'people':
return Icons.people;
case 'attach_money':
return Icons.attach_money;
case 'event':
return Icons.event;
case 'favorite':
return Icons.favorite;
case 'trending_up':
return Icons.trending_up;
case 'business':
return Icons.business;
case 'settings':
return Icons.settings;
default:
return Icons.analytics;
}
}
/// Formate une valeur selon le type de métrique
String _formaterValeur(double valeur) {
switch (analyticsData.typeMetrique.typeValeur) {
case 'amount':
return '${valeur.toStringAsFixed(0)} ${analyticsData.unite}';
case 'percentage':
return '${valeur.toStringAsFixed(1)}${analyticsData.unite}';
case 'average':
return valeur.toStringAsFixed(1);
default:
return valeur.toStringAsFixed(0);
}
}
}

View File

@@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import '../../../../shared/widgets/common/unified_card.dart';
import '../../../../shared/theme/design_system.dart';
import '../../domain/entities/analytics_data.dart';
/// Widget de sélection de période pour les analytics
class PeriodSelectorWidget extends StatelessWidget {
const PeriodSelectorWidget({
super.key,
required this.periodeSelectionnee,
required this.onPeriodeChanged,
this.compact = false,
});
final PeriodeAnalyse periodeSelectionnee;
final ValueChanged<PeriodeAnalyse> onPeriodeChanged;
final bool compact;
@override
Widget build(BuildContext context) {
if (compact) {
return _buildCompactSelector(context);
} else {
return _buildFullSelector(context);
}
}
/// Sélecteur compact avec dropdown
Widget _buildCompactSelector(BuildContext context) {
return UnifiedCard(
variant: UnifiedCardVariant.outlined,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacing16,
vertical: DesignSystem.spacing8,
),
child: DropdownButtonHideUnderline(
child: DropdownButton<PeriodeAnalyse>(
value: periodeSelectionnee,
onChanged: (periode) {
if (periode != null) {
onPeriodeChanged(periode);
}
},
icon: const Icon(Icons.expand_more),
isExpanded: true,
items: PeriodeAnalyse.values.map((periode) {
return DropdownMenuItem<PeriodeAnalyse>(
value: periode,
child: Text(
periode.libelle,
style: DesignSystem.textTheme.bodyMedium,
),
);
}).toList(),
),
),
),
);
}
/// Sélecteur complet avec chips
Widget _buildFullSelector(BuildContext context) {
final periodesRapides = [
PeriodeAnalyse.aujourdHui,
PeriodeAnalyse.hier,
PeriodeAnalyse.cetteSemaine,
PeriodeAnalyse.ceMois,
PeriodeAnalyse.troisDerniersMois,
PeriodeAnalyse.cetteAnnee,
];
final periodesPersonnalisees = [
PeriodeAnalyse.septDerniersJours,
PeriodeAnalyse.trenteDerniersJours,
PeriodeAnalyse.sixDerniersMois,
PeriodeAnalyse.anneeDerniere,
PeriodeAnalyse.periodePersonnalisee,
];
return UnifiedCard(
variant: UnifiedCardVariant.outlined,
child: Padding(
padding: const EdgeInsets.all(DesignSystem.spacing16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
Row(
children: [
Icon(
Icons.date_range,
size: 20,
color: DesignSystem.primaryColor,
),
const SizedBox(width: DesignSystem.spacing8),
Text(
'Période d\'analyse',
style: DesignSystem.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: DesignSystem.spacing12),
// Périodes rapides
Text(
'Accès rapide',
style: DesignSystem.textTheme.bodySmall?.copyWith(
color: DesignSystem.textSecondaryColor,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: DesignSystem.spacing8),
Wrap(
spacing: DesignSystem.spacing8,
runSpacing: DesignSystem.spacing8,
children: periodesRapides.map((periode) {
return _buildPeriodeChip(periode, isRapide: true);
}).toList(),
),
const SizedBox(height: DesignSystem.spacing16),
// Périodes personnalisées
Text(
'Autres périodes',
style: DesignSystem.textTheme.bodySmall?.copyWith(
color: DesignSystem.textSecondaryColor,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: DesignSystem.spacing8),
Wrap(
spacing: DesignSystem.spacing8,
runSpacing: DesignSystem.spacing8,
children: periodesPersonnalisees.map((periode) {
return _buildPeriodeChip(periode, isRapide: false);
}).toList(),
),
// Informations sur la période sélectionnée
if (periodeSelectionnee != PeriodeAnalyse.periodePersonnalisee) ...[
const SizedBox(height: DesignSystem.spacing16),
_buildInfosPeriode(),
],
],
),
),
);
}
/// Chip de sélection de période
Widget _buildPeriodeChip(PeriodeAnalyse periode, {required bool isRapide}) {
final isSelected = periode == periodeSelectionnee;
return FilterChip(
label: Text(
periode.libelle,
style: DesignSystem.textTheme.bodySmall?.copyWith(
color: isSelected
? Colors.white
: isRapide
? DesignSystem.primaryColor
: DesignSystem.textSecondaryColor,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
selected: isSelected,
onSelected: (_) => onPeriodeChanged(periode),
backgroundColor: isRapide
? DesignSystem.primaryColor.withOpacity(0.1)
: DesignSystem.surfaceColor,
selectedColor: isRapide
? DesignSystem.primaryColor
: DesignSystem.secondaryColor,
checkmarkColor: Colors.white,
side: BorderSide(
color: isSelected
? Colors.transparent
: isRapide
? DesignSystem.primaryColor.withOpacity(0.3)
: DesignSystem.borderColor,
width: 1,
),
elevation: isSelected ? 2 : 0,
pressElevation: 4,
);
}
/// Informations sur la période sélectionnée
Widget _buildInfosPeriode() {
return Container(
padding: const EdgeInsets.all(DesignSystem.spacing12),
decoration: BoxDecoration(
color: DesignSystem.primaryColor.withOpacity(0.05),
borderRadius: BorderRadius.circular(DesignSystem.radius8),
border: Border.all(
color: DesignSystem.primaryColor.withOpacity(0.2),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: DesignSystem.primaryColor,
),
const SizedBox(width: DesignSystem.spacing8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Période sélectionnée : ${periodeSelectionnee.libelle}',
style: DesignSystem.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: DesignSystem.spacing2),
Text(
_getDescriptionPeriode(),
style: DesignSystem.textTheme.bodySmall?.copyWith(
color: DesignSystem.textSecondaryColor,
),
),
],
),
),
],
),
);
}
/// Description de la période sélectionnée
String _getDescriptionPeriode() {
switch (periodeSelectionnee) {
case PeriodeAnalyse.aujourdHui:
return 'Données du jour en cours';
case PeriodeAnalyse.hier:
return 'Données de la journée précédente';
case PeriodeAnalyse.cetteSemaine:
return 'Du lundi au dimanche de cette semaine';
case PeriodeAnalyse.semaineDerniere:
return 'Du lundi au dimanche de la semaine passée';
case PeriodeAnalyse.ceMois:
return 'Du 1er au dernier jour de ce mois';
case PeriodeAnalyse.moisDernier:
return 'Du 1er au dernier jour du mois passé';
case PeriodeAnalyse.troisDerniersMois:
return 'Les 3 derniers mois complets';
case PeriodeAnalyse.sixDerniersMois:
return 'Les 6 derniers mois complets';
case PeriodeAnalyse.cetteAnnee:
return 'Du 1er janvier à aujourd\'hui';
case PeriodeAnalyse.anneeDerniere:
return 'Du 1er janvier au 31 décembre de l\'année passée';
case PeriodeAnalyse.septDerniersJours:
return 'Les 7 derniers jours glissants';
case PeriodeAnalyse.trenteDerniersJours:
return 'Les 30 derniers jours glissants';
case PeriodeAnalyse.periodePersonnalisee:
return 'Définissez vos propres dates de début et fin';
}
}
}

View File

@@ -298,3 +298,23 @@ class ExportCotisations extends CotisationsEvent {
@override
List<Object?> get props => [format, cotisations];
}
/// Événement pour charger l'historique des paiements
class LoadPaymentHistory extends CotisationsEvent {
final String? membreId;
final String? period;
final String? status;
final String? method;
final String? searchQuery;
const LoadPaymentHistory({
this.membreId,
this.period,
this.status,
this.method,
this.searchQuery,
});
@override
List<Object?> get props => [membreId, period, status, method, searchQuery];
}

View File

@@ -380,3 +380,13 @@ class NotificationsScheduled extends CotisationsState {
@override
List<Object?> get props => [notificationsCount, cotisationIds];
}
/// État d'historique des paiements chargé
class PaymentHistoryLoaded extends CotisationsState {
final List<PaymentModel> payments;
const PaymentHistoryLoaded(this.payments);
@override
List<Object?> get props => [payments];
}

View File

@@ -0,0 +1,565 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/custom_text_field.dart';
import '../../../../shared/widgets/loading_button.dart';
import '../bloc/cotisations_bloc.dart';
import '../bloc/cotisations_event.dart';
import '../bloc/cotisations_state.dart';
/// Page de création d'une nouvelle cotisation
class CotisationCreatePage extends StatefulWidget {
final MembreModel? membre; // Membre pré-sélectionné (optionnel)
const CotisationCreatePage({
super.key,
this.membre,
});
@override
State<CotisationCreatePage> createState() => _CotisationCreatePageState();
}
class _CotisationCreatePageState extends State<CotisationCreatePage> {
final _formKey = GlobalKey<FormState>();
late CotisationsBloc _cotisationsBloc;
// Contrôleurs de champs
final _montantController = TextEditingController();
final _descriptionController = TextEditingController();
final _periodeController = TextEditingController();
// Valeurs sélectionnées
String _typeCotisation = 'MENSUELLE';
DateTime _dateEcheance = DateTime.now().add(const Duration(days: 30));
MembreModel? _membreSelectionne;
// Options disponibles
final List<String> _typesCotisation = [
'MENSUELLE',
'TRIMESTRIELLE',
'SEMESTRIELLE',
'ANNUELLE',
'EXCEPTIONNELLE',
];
@override
void initState() {
super.initState();
_cotisationsBloc = getIt<CotisationsBloc>();
_membreSelectionne = widget.membre;
// Pré-remplir la période selon le type
_updatePeriodeFromType();
}
@override
void dispose() {
_montantController.dispose();
_descriptionController.dispose();
_periodeController.dispose();
super.dispose();
}
void _updatePeriodeFromType() {
final now = DateTime.now();
String periode;
switch (_typeCotisation) {
case 'MENSUELLE':
periode = '${_getMonthName(now.month)} ${now.year}';
break;
case 'TRIMESTRIELLE':
final trimestre = ((now.month - 1) ~/ 3) + 1;
periode = 'T$trimestre ${now.year}';
break;
case 'SEMESTRIELLE':
final semestre = now.month <= 6 ? 1 : 2;
periode = 'S$semestre ${now.year}';
break;
case 'ANNUELLE':
periode = '${now.year}';
break;
case 'EXCEPTIONNELLE':
periode = 'Exceptionnelle ${now.day}/${now.month}/${now.year}';
break;
default:
periode = '${now.month}/${now.year}';
}
_periodeController.text = periode;
}
String _getMonthName(int month) {
const months = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
];
return months[month - 1];
}
void _onTypeChanged(String? newType) {
if (newType != null) {
setState(() {
_typeCotisation = newType;
_updatePeriodeFromType();
});
}
}
Future<void> _selectDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _dateEcheance,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
locale: const Locale('fr', 'FR'),
);
if (picked != null) {
setState(() {
_dateEcheance = picked;
});
}
}
Future<void> _selectMembre() async {
// TODO: Implémenter la sélection de membre
// Pour l'instant, afficher un message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité de sélection de membre à implémenter'),
backgroundColor: AppTheme.infoColor,
),
);
}
void _createCotisation() {
if (!_formKey.currentState!.validate()) {
return;
}
if (_membreSelectionne == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez sélectionner un membre'),
backgroundColor: AppTheme.errorColor,
),
);
return;
}
final montant = double.tryParse(_montantController.text.replaceAll(' ', ''));
if (montant == null || montant <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez saisir un montant valide'),
backgroundColor: AppTheme.errorColor,
),
);
return;
}
// Créer la cotisation
final cotisation = CotisationModel(
id: '', // Sera généré par le backend
numeroReference: '', // Sera généré par le backend
membreId: _membreSelectionne!.id ?? '',
nomMembre: _membreSelectionne!.nomComplet,
typeCotisation: _typeCotisation,
montantDu: montant,
montantPaye: 0.0,
dateEcheance: _dateEcheance,
statut: 'EN_ATTENTE',
description: _descriptionController.text.trim(),
periode: _periodeController.text.trim(),
annee: _dateEcheance.year,
mois: _dateEcheance.month,
codeDevise: 'XOF',
recurrente: _typeCotisation != 'EXCEPTIONNELLE',
nombreRappels: 0,
dateCreation: DateTime.now(),
dateModification: DateTime.now(),
);
_cotisationsBloc.add(CreateCotisation(cotisation));
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cotisationsBloc,
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: AppBar(
title: const Text('Nouvelle Cotisation'),
backgroundColor: AppTheme.accentColor,
foregroundColor: Colors.white,
elevation: 0,
),
body: BlocListener<CotisationsBloc, CotisationsState>(
listener: (context, state) {
if (state is CotisationCreated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cotisation créée avec succès'),
backgroundColor: AppTheme.successColor,
),
);
Navigator.of(context).pop(true);
} else if (state is CotisationsError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppTheme.errorColor,
),
);
}
},
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Sélection du membre
_buildMembreSection(),
const SizedBox(height: 24),
// Type de cotisation
_buildTypeSection(),
const SizedBox(height: 24),
// Montant
_buildMontantSection(),
const SizedBox(height: 24),
// Période et échéance
_buildPeriodeSection(),
const SizedBox(height: 24),
// Description
_buildDescriptionSection(),
const SizedBox(height: 32),
// Bouton de création
_buildCreateButton(),
],
),
),
),
),
),
);
}
Widget _buildMembreSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Membre',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
if (_membreSelectionne != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.accentColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.accentColor.withOpacity(0.3)),
),
child: Row(
children: [
CircleAvatar(
backgroundColor: AppTheme.accentColor,
child: Text(
_membreSelectionne!.nomComplet.substring(0, 1).toUpperCase(),
style: const TextStyle(color: Colors.white),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_membreSelectionne!.nomComplet,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
Text(
_membreSelectionne!.telephone.isNotEmpty
? _membreSelectionne!.telephone
: 'Pas de téléphone',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.change_circle),
onPressed: _selectMembre,
color: AppTheme.accentColor,
),
],
),
)
else
ElevatedButton.icon(
onPressed: _selectMembre,
icon: const Icon(Icons.person_add),
label: const Text('Sélectionner un membre'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentColor,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
),
),
],
),
),
);
}
Widget _buildTypeSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Type de cotisation',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _typeCotisation,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: _typesCotisation.map((type) {
return DropdownMenuItem(
value: type,
child: Text(_getTypeLabel(type)),
);
}).toList(),
onChanged: _onTypeChanged,
),
],
),
),
);
}
String _getTypeLabel(String type) {
switch (type) {
case 'MENSUELLE': return 'Mensuelle';
case 'TRIMESTRIELLE': return 'Trimestrielle';
case 'SEMESTRIELLE': return 'Semestrielle';
case 'ANNUELLE': return 'Annuelle';
case 'EXCEPTIONNELLE': return 'Exceptionnelle';
default: return type;
}
}
Widget _buildMontantSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Montant',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
CustomTextField(
controller: _montantController,
label: 'Montant (XOF)',
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
TextInputFormatter.withFunction((oldValue, newValue) {
// Formater avec des espaces pour les milliers
final text = newValue.text.replaceAll(' ', '');
if (text.isEmpty) return newValue;
final number = int.tryParse(text);
if (number == null) return oldValue;
final formatted = number.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]} ',
);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}),
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir un montant';
}
final montant = double.tryParse(value.replaceAll(' ', ''));
if (montant == null || montant <= 0) {
return 'Veuillez saisir un montant valide';
}
return null;
},
suffixIcon: const Icon(Icons.attach_money),
),
],
),
),
);
}
Widget _buildPeriodeSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Période et échéance',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
CustomTextField(
controller: _periodeController,
label: 'Période',
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir une période';
}
return null;
},
),
const SizedBox(height: 16),
InkWell(
onTap: _selectDate,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.calendar_today, color: AppTheme.accentColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Date d\'échéance',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
Text(
'${_dateEcheance.day}/${_dateEcheance.month}/${_dateEcheance.year}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
],
),
),
const Icon(Icons.arrow_drop_down),
],
),
),
),
],
),
),
);
}
Widget _buildDescriptionSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Description (optionnelle)',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
CustomTextField(
controller: _descriptionController,
label: 'Description de la cotisation',
maxLines: 3,
maxLength: 500,
),
],
),
),
);
}
Widget _buildCreateButton() {
return BlocBuilder<CotisationsBloc, CotisationsState>(
builder: (context, state) {
final isLoading = state is CotisationsLoading;
return LoadingButton(
onPressed: isLoading ? null : _createCotisation,
isLoading: isLoading,
text: 'Créer la cotisation',
backgroundColor: AppTheme.accentColor,
textColor: Colors.white,
);
},
);
}
}

View File

@@ -12,6 +12,7 @@ import '../bloc/cotisations_event.dart';
import '../bloc/cotisations_state.dart';
import '../widgets/payment_method_selector.dart';
import '../widgets/payment_form_widget.dart';
import '../widgets/wave_payment_widget.dart';
import '../widgets/cotisation_timeline_widget.dart';
/// Page de détail d'une cotisation
@@ -422,18 +423,61 @@ class _CotisationDetailPageState extends State<CotisationDetailPage>
);
}
return PaymentFormWidget(
cotisation: widget.cotisation,
onPaymentInitiated: (paymentData) {
_cotisationsBloc.add(InitiatePayment(
cotisationId: widget.cotisation.id,
montant: paymentData['montant'],
methodePaiement: paymentData['methodePaiement'],
numeroTelephone: paymentData['numeroTelephone'],
nomPayeur: paymentData['nomPayeur'],
emailPayeur: paymentData['emailPayeur'],
));
},
return Column(
children: [
// Widget Wave Money en priorité
WavePaymentWidget(
cotisation: widget.cotisation,
showFullInterface: true,
onPaymentInitiated: () {
// Feedback visuel lors de l'initiation du paiement
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Redirection vers Wave Money...'),
backgroundColor: Color(0xFF00D4FF),
duration: Duration(seconds: 2),
),
);
},
),
const SizedBox(height: 16),
// Séparateur avec texte
Row(
children: [
const Expanded(child: Divider()),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Text(
'Ou choisir une autre méthode',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 12,
),
),
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: 16),
// Formulaire de paiement classique
PaymentFormWidget(
cotisation: widget.cotisation,
onPaymentInitiated: (paymentData) {
_cotisationsBloc.add(InitiatePayment(
cotisationId: widget.cotisation.id,
montant: paymentData['montant'],
methodePaiement: paymentData['methodePaiement'],
numeroTelephone: paymentData['numeroTelephone'],
nomPayeur: paymentData['nomPayeur'],
emailPayeur: paymentData['emailPayeur'],
));
},
),
],
);
},
);

View File

@@ -11,6 +11,9 @@ import '../widgets/cotisations_stats_card.dart';
import 'cotisation_detail_page.dart';
import 'cotisations_search_page.dart';
// Import de l'architecture unifiée pour amélioration progressive
import '../../../../shared/widgets/common/unified_page_layout.dart';
/// Page principale pour la liste des cotisations
class CotisationsListPage extends StatefulWidget {
const CotisationsListPage({super.key});
@@ -64,69 +67,96 @@ class _CotisationsListPageState extends State<CotisationsListPage> {
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cotisationsBloc,
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: Column(
children: [
// Header personnalisé
_buildHeader(),
// Contenu principal
Expanded(
child: BlocBuilder<CotisationsBloc, CotisationsState>(
builder: (context, state) {
if (state is CotisationsInitial ||
(state is CotisationsLoading && !state.isRefreshing)) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state is CotisationsError) {
return _buildErrorState(state);
}
if (state is CotisationsLoaded) {
return _buildLoadedState(state);
}
// État par défaut - Coming Soon
return const ComingSoonPage(
title: 'Module Cotisations',
description: 'Gestion complète des cotisations avec paiements automatiques',
icon: Icons.payment_rounded,
color: AppTheme.accentColor,
features: [
'Tableau de bord des cotisations',
'Relances automatiques par email/SMS',
'Paiements en ligne sécurisés',
'Génération de reçus automatique',
'Suivi des retards de paiement',
'Rapports financiers détaillés',
],
child: BlocBuilder<CotisationsBloc, CotisationsState>(
builder: (context, state) {
// Utilisation de UnifiedPageLayout pour améliorer la cohérence
// tout en conservant le header personnalisé et toutes les fonctionnalités
return UnifiedPageLayout(
title: 'Cotisations',
subtitle: 'Gérez les cotisations de vos membres',
icon: Icons.payment_rounded,
iconColor: AppTheme.accentColor,
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CotisationsSearchPage(),
),
);
},
tooltip: 'Rechercher',
),
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CotisationsSearchPage(),
),
);
},
tooltip: 'Filtrer',
),
],
isLoading: state is CotisationsInitial ||
(state is CotisationsLoading && !state.isRefreshing),
errorMessage: state is CotisationsError ? state.message : null,
onRefresh: () {
_cotisationsBloc.add(const LoadCotisations(refresh: true));
_cotisationsBloc.add(const LoadCotisationsStats());
},
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO: Implémenter la création de cotisation
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Création de cotisation - En cours de développement'),
backgroundColor: AppTheme.accentColor,
),
);
},
backgroundColor: AppTheme.accentColor,
child: const Icon(Icons.add, color: Colors.white),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO: Implémenter la création de cotisation
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Création de cotisation - En cours de développement'),
backgroundColor: AppTheme.accentColor,
),
);
},
backgroundColor: AppTheme.accentColor,
child: const Icon(Icons.add, color: Colors.white),
),
body: _buildContent(state),
);
},
),
);
}
/// Construit le contenu principal en fonction de l'état
/// CONSERVÉ: Toute la logique d'état et les widgets spécialisés
Widget _buildContent(CotisationsState state) {
if (state is CotisationsError) {
return _buildErrorState(state);
}
if (state is CotisationsLoaded) {
return _buildLoadedState(state);
}
// État par défaut - Coming Soon avec toutes les fonctionnalités prévues
return const ComingSoonPage(
title: 'Module Cotisations',
description: 'Gestion complète des cotisations avec paiements automatiques',
icon: Icons.payment_rounded,
color: AppTheme.accentColor,
features: [
'Tableau de bord des cotisations',
'Relances automatiques par email/SMS',
'Paiements en ligne sécurisés',
'Génération de reçus automatique',
'Suivi des retards de paiement',
'Rapports financiers détaillés',
],
);
}
Widget _buildHeader() {
return Container(
width: double.infinity,

View File

@@ -0,0 +1,596 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/widgets/unified_components.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../core/models/cotisation_model.dart';
import '../bloc/cotisations_bloc.dart';
import '../bloc/cotisations_event.dart';
import '../bloc/cotisations_state.dart';
import 'cotisation_create_page.dart';
import 'payment_history_page.dart';
import 'cotisation_detail_page.dart';
import '../widgets/wave_payment_widget.dart';
/// Page des cotisations UnionFlow - Version Unifiée
///
/// Utilise l'architecture unifiée pour une expérience cohérente :
/// - Composants standardisés réutilisables
/// - Interface homogène avec les autres onglets
/// - Performance optimisée avec animations fluides
/// - Maintenabilité maximale
class CotisationsListPageUnified extends StatefulWidget {
const CotisationsListPageUnified({super.key});
@override
State<CotisationsListPageUnified> createState() => _CotisationsListPageUnifiedState();
}
class _CotisationsListPageUnifiedState extends State<CotisationsListPageUnified> {
late final CotisationsBloc _cotisationsBloc;
String _currentFilter = 'all';
@override
void initState() {
super.initState();
_cotisationsBloc = getIt<CotisationsBloc>();
_loadData();
}
void _loadData() {
_cotisationsBloc.add(const LoadCotisations());
_cotisationsBloc.add(const LoadCotisationsStats());
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cotisationsBloc,
child: BlocBuilder<CotisationsBloc, CotisationsState>(
builder: (context, state) {
return UnifiedPageLayout(
title: 'Cotisations',
subtitle: 'Gestion des cotisations de l\'association',
icon: Icons.account_balance_wallet,
iconColor: AppTheme.successColor,
isLoading: state is CotisationsLoading,
errorMessage: state is CotisationsError ? state.message : null,
onRefresh: _loadData,
actions: _buildActions(),
body: Column(
children: [
_buildKPISection(state),
const SizedBox(height: AppTheme.spacingLarge),
_buildQuickActionsSection(),
const SizedBox(height: AppTheme.spacingLarge),
_buildFiltersSection(),
const SizedBox(height: AppTheme.spacingLarge),
Expanded(child: _buildCotisationsList(state)),
],
),
);
},
),
);
}
/// Actions de la barre d'outils
List<Widget> _buildActions() {
return [
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
// TODO: Navigation vers ajout cotisation
},
tooltip: 'Nouvelle cotisation',
),
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
// TODO: Navigation vers recherche
},
tooltip: 'Rechercher',
),
IconButton(
icon: const Icon(Icons.analytics),
onPressed: () {
// TODO: Navigation vers analyses
},
tooltip: 'Analyses',
),
];
}
/// Section des KPI des cotisations
Widget _buildKPISection(CotisationsState state) {
final cotisations = state is CotisationsLoaded ? state.cotisations : <CotisationModel>[];
final totalCotisations = cotisations.length;
final cotisationsPayees = cotisations.where((c) => c.statut == 'PAYEE').length;
final cotisationsEnAttente = cotisations.where((c) => c.statut == 'EN_ATTENTE').length;
final montantTotal = cotisations.fold<double>(0, (sum, c) => sum + c.montantDu);
final kpis = [
UnifiedKPIData(
title: 'Total',
value: totalCotisations.toString(),
icon: Icons.receipt,
color: AppTheme.primaryColor,
trend: UnifiedKPITrend(
direction: UnifiedKPITrendDirection.stable,
value: 'Total',
label: 'cotisations',
),
),
UnifiedKPIData(
title: 'Payées',
value: cotisationsPayees.toString(),
icon: Icons.check_circle,
color: AppTheme.successColor,
trend: UnifiedKPITrend(
direction: UnifiedKPITrendDirection.up,
value: '${((cotisationsPayees / totalCotisations) * 100).toInt()}%',
label: 'du total',
),
),
UnifiedKPIData(
title: 'En attente',
value: cotisationsEnAttente.toString(),
icon: Icons.pending,
color: AppTheme.warningColor,
trend: UnifiedKPITrend(
direction: UnifiedKPITrendDirection.down,
value: '${((cotisationsEnAttente / totalCotisations) * 100).toInt()}%',
label: 'du total',
),
),
UnifiedKPIData(
title: 'Montant',
value: '${montantTotal.toStringAsFixed(0)}',
icon: Icons.euro,
color: AppTheme.accentColor,
trend: UnifiedKPITrend(
direction: UnifiedKPITrendDirection.up,
value: 'Total',
label: 'collecté',
),
),
];
return UnifiedKPISection(
title: 'Statistiques des cotisations',
kpis: kpis,
);
}
/// Section des actions rapides
Widget _buildQuickActionsSection() {
final actions = [
UnifiedQuickAction(
id: 'add_cotisation',
title: 'Nouvelle\nCotisation',
icon: Icons.add_card,
color: AppTheme.primaryColor,
),
UnifiedQuickAction(
id: 'bulk_payment',
title: 'Paiement\nGroupé',
icon: Icons.payment,
color: AppTheme.successColor,
),
UnifiedQuickAction(
id: 'send_reminder',
title: 'Envoyer\nRappels',
icon: Icons.notification_important,
color: AppTheme.warningColor,
badgeCount: 15,
),
UnifiedQuickAction(
id: 'export_data',
title: 'Exporter\nDonnées',
icon: Icons.download,
color: AppTheme.infoColor,
),
UnifiedQuickAction(
id: 'payment_history',
title: 'Historique\nPaiements',
icon: Icons.history,
color: AppTheme.accentColor,
),
UnifiedQuickAction(
id: 'reports',
title: 'Rapports\nFinanciers',
icon: Icons.analytics,
color: AppTheme.textSecondary,
),
];
return UnifiedQuickActionsSection(
title: 'Actions rapides',
actions: actions,
onActionTap: _handleQuickAction,
);
}
/// Section des filtres
Widget _buildFiltersSection() {
return UnifiedCard.outlined(
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.filter_list,
color: AppTheme.successColor,
size: 20,
),
const SizedBox(width: AppTheme.spacingSmall),
Text(
'Filtres rapides',
style: AppTheme.titleSmall.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: AppTheme.spacingMedium),
Wrap(
spacing: AppTheme.spacingSmall,
runSpacing: AppTheme.spacingSmall,
children: [
_buildFilterChip('Toutes', 'all'),
_buildFilterChip('Payées', 'payee'),
_buildFilterChip('En attente', 'en_attente'),
_buildFilterChip('En retard', 'en_retard'),
_buildFilterChip('Annulées', 'annulee'),
],
),
],
),
),
);
}
/// Construit un chip de filtre
Widget _buildFilterChip(String label, String value) {
final isSelected = _currentFilter == value;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_currentFilter = selected ? value : 'all';
});
// TODO: Appliquer le filtre
},
selectedColor: AppTheme.successColor.withOpacity(0.2),
checkmarkColor: AppTheme.successColor,
);
}
/// Liste des cotisations avec composant unifié
Widget _buildCotisationsList(CotisationsState state) {
if (state is CotisationsLoaded) {
final filteredCotisations = _filterCotisations(state.cotisations);
return UnifiedListWidget<CotisationModel>(
items: filteredCotisations,
itemBuilder: (context, cotisation, index) => _buildCotisationCard(cotisation),
isLoading: false,
hasReachedMax: state.hasReachedMax,
enableAnimations: true,
emptyMessage: 'Aucune cotisation trouvée',
emptyIcon: Icons.receipt_outlined,
onLoadMore: () {
// TODO: Charger plus de cotisations
},
);
}
return const Center(
child: Text('Chargement des cotisations...'),
);
}
/// Filtre les cotisations selon le filtre actuel
List<CotisationModel> _filterCotisations(List<CotisationModel> cotisations) {
if (_currentFilter == 'all') return cotisations;
return cotisations.where((cotisation) {
switch (_currentFilter) {
case 'payee':
return cotisation.statut == 'PAYEE';
case 'en_attente':
return cotisation.statut == 'EN_ATTENTE';
case 'en_retard':
return cotisation.statut == 'EN_RETARD';
case 'annulee':
return cotisation.statut == 'ANNULEE';
default:
return true;
}
}).toList();
}
/// Construit une carte de cotisation
Widget _buildCotisationCard(CotisationModel cotisation) {
return UnifiedCard.listItem(
onTap: () {
// TODO: Navigation vers détails de la cotisation
},
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(AppTheme.spacingSmall),
decoration: BoxDecoration(
color: _getStatusColor(cotisation.statut).withOpacity(0.1),
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
),
child: Icon(
_getStatusIcon(cotisation.statut),
color: _getStatusColor(cotisation.statut),
size: 20,
),
),
const SizedBox(width: AppTheme.spacingMedium),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
cotisation.typeCotisation,
style: AppTheme.bodyLarge.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppTheme.spacingXSmall),
Text(
'Membre: ${cotisation.nomMembre ?? 'N/A'}',
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${cotisation.montantDu.toStringAsFixed(2)}',
style: AppTheme.titleMedium.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.successColor,
),
),
const SizedBox(height: AppTheme.spacingXSmall),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingSmall,
vertical: AppTheme.spacingXSmall,
),
decoration: BoxDecoration(
color: _getStatusColor(cotisation.statut).withOpacity(0.1),
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
),
child: Text(
_getStatusLabel(cotisation.statut),
style: AppTheme.bodySmall.copyWith(
color: _getStatusColor(cotisation.statut),
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
const SizedBox(height: AppTheme.spacingMedium),
Row(
children: [
Icon(
Icons.calendar_today,
size: 16,
color: AppTheme.textSecondary,
),
const SizedBox(width: AppTheme.spacingXSmall),
Text(
'Échéance: ${cotisation.dateEcheance.day}/${cotisation.dateEcheance.month}/${cotisation.dateEcheance.year}',
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textSecondary,
),
),
const Spacer(),
if (cotisation.datePaiement != null) ...[
Icon(
Icons.check_circle,
size: 16,
color: AppTheme.successColor,
),
const SizedBox(width: AppTheme.spacingXSmall),
Text(
'Payée le ${cotisation.datePaiement!.day}/${cotisation.datePaiement!.month}/${cotisation.datePaiement!.year}',
style: AppTheme.bodySmall.copyWith(
color: AppTheme.successColor,
),
),
],
],
),
],
),
),
);
}
/// Obtient la couleur du statut
Color _getStatusColor(String statut) {
switch (statut) {
case 'PAYEE':
return AppTheme.successColor;
case 'EN_ATTENTE':
return AppTheme.warningColor;
case 'EN_RETARD':
return AppTheme.errorColor;
case 'ANNULEE':
return AppTheme.textSecondary;
default:
return AppTheme.textSecondary;
}
}
/// Obtient l'icône du statut
IconData _getStatusIcon(String statut) {
switch (statut) {
case 'PAYEE':
return Icons.check_circle;
case 'EN_ATTENTE':
return Icons.pending;
case 'EN_RETARD':
return Icons.warning;
case 'ANNULEE':
return Icons.cancel;
default:
return Icons.help;
}
}
/// Obtient le libellé du statut
String _getStatusLabel(String statut) {
switch (statut) {
case 'PAYEE':
return 'Payée';
case 'EN_ATTENTE':
return 'En attente';
case 'EN_RETARD':
return 'En retard';
case 'ANNULEE':
return 'Annulée';
default:
return 'Inconnu';
}
}
/// Gère les actions rapides
void _handleQuickAction(UnifiedQuickAction action) {
switch (action.id) {
case 'add_cotisation':
_navigateToCreateCotisation();
break;
case 'bulk_payment':
_showBulkPaymentDialog();
break;
case 'send_reminder':
_showSendReminderDialog();
break;
case 'export_data':
_exportCotisationsData();
break;
case 'payment_history':
_navigateToPaymentHistory();
break;
case 'reports':
_showReportsDialog();
break;
}
}
/// Navigation vers la création de cotisation
void _navigateToCreateCotisation() async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CotisationCreatePage(),
),
);
if (result == true) {
// Recharger la liste si une cotisation a été créée
_loadData();
}
}
/// Navigation vers l'historique des paiements
void _navigateToPaymentHistory() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PaymentHistoryPage(),
),
);
}
/// Affiche le dialogue de paiement groupé
void _showBulkPaymentDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Paiement Groupé'),
content: const Text('Fonctionnalité de paiement groupé à implémenter'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
/// Affiche le dialogue d'envoi de rappels
void _showSendReminderDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Envoyer des Rappels'),
content: const Text('Fonctionnalité d\'envoi de rappels à implémenter'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
/// Export des données de cotisations
void _exportCotisationsData() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité d\'export à implémenter'),
backgroundColor: AppTheme.infoColor,
),
);
}
/// Affiche le dialogue des rapports
void _showReportsDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Rapports Financiers'),
content: const Text('Fonctionnalité de rapports financiers à implémenter'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
@override
void dispose() {
_cotisationsBloc.close();
super.dispose();
}
}

View File

@@ -0,0 +1,612 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../core/models/payment_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/common/unified_page_layout.dart';
import '../../../../shared/widgets/common/unified_search_bar.dart';
import '../../../../shared/widgets/common/unified_filter_chip.dart';
import '../../../../shared/widgets/common/unified_empty_state.dart';
import '../../../../shared/widgets/common/unified_loading_indicator.dart';
import '../bloc/cotisations_bloc.dart';
import '../bloc/cotisations_event.dart';
import '../bloc/cotisations_state.dart';
/// Page d'historique des paiements
class PaymentHistoryPage extends StatefulWidget {
final String? membreId; // Filtrer par membre (optionnel)
const PaymentHistoryPage({
super.key,
this.membreId,
});
@override
State<PaymentHistoryPage> createState() => _PaymentHistoryPageState();
}
class _PaymentHistoryPageState extends State<PaymentHistoryPage> {
late CotisationsBloc _cotisationsBloc;
final _searchController = TextEditingController();
// Filtres
String _selectedPeriod = 'all';
String _selectedStatus = 'all';
String _selectedMethod = 'all';
// Options de filtres
final List<Map<String, String>> _periodOptions = [
{'value': 'all', 'label': 'Toutes les périodes'},
{'value': 'today', 'label': 'Aujourd\'hui'},
{'value': 'week', 'label': 'Cette semaine'},
{'value': 'month', 'label': 'Ce mois'},
{'value': 'year', 'label': 'Cette année'},
];
final List<Map<String, String>> _statusOptions = [
{'value': 'all', 'label': 'Tous les statuts'},
{'value': 'COMPLETED', 'label': 'Complété'},
{'value': 'PENDING', 'label': 'En attente'},
{'value': 'FAILED', 'label': 'Échoué'},
{'value': 'CANCELLED', 'label': 'Annulé'},
];
final List<Map<String, String>> _methodOptions = [
{'value': 'all', 'label': 'Toutes les méthodes'},
{'value': 'WAVE', 'label': 'Wave Money'},
{'value': 'ORANGE_MONEY', 'label': 'Orange Money'},
{'value': 'MTN_MONEY', 'label': 'MTN Money'},
{'value': 'CASH', 'label': 'Espèces'},
{'value': 'BANK_TRANSFER', 'label': 'Virement bancaire'},
];
@override
void initState() {
super.initState();
_cotisationsBloc = getIt<CotisationsBloc>();
_loadPaymentHistory();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _loadPaymentHistory() {
_cotisationsBloc.add(LoadPaymentHistory(
membreId: widget.membreId,
period: _selectedPeriod,
status: _selectedStatus,
method: _selectedMethod,
searchQuery: _searchController.text.trim(),
));
}
void _onSearchChanged(String query) {
// Debounce la recherche
Future.delayed(const Duration(milliseconds: 500), () {
if (_searchController.text == query) {
_loadPaymentHistory();
}
});
}
void _onFilterChanged() {
_loadPaymentHistory();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cotisationsBloc,
child: UnifiedPageLayout(
title: 'Historique des Paiements',
backgroundColor: AppTheme.backgroundLight,
actions: [
IconButton(
icon: const Icon(Icons.file_download),
onPressed: _exportHistory,
tooltip: 'Exporter',
),
],
body: Column(
children: [
// Barre de recherche
Padding(
padding: const EdgeInsets.all(16),
child: UnifiedSearchBar(
controller: _searchController,
hintText: 'Rechercher par membre, référence...',
onChanged: _onSearchChanged,
),
),
// Filtres
_buildFilters(),
// Liste des paiements
Expanded(
child: BlocBuilder<CotisationsBloc, CotisationsState>(
builder: (context, state) {
if (state is CotisationsLoading) {
return const UnifiedLoadingIndicator();
} else if (state is PaymentHistoryLoaded) {
if (state.payments.isEmpty) {
return UnifiedEmptyState(
icon: Icons.payment,
title: 'Aucun paiement trouvé',
subtitle: 'Aucun paiement ne correspond à vos critères de recherche',
actionText: 'Réinitialiser les filtres',
onActionPressed: _resetFilters,
);
}
return _buildPaymentsList(state.payments);
} else if (state is CotisationsError) {
return UnifiedEmptyState(
icon: Icons.error,
title: 'Erreur de chargement',
subtitle: state.message,
actionText: 'Réessayer',
onActionPressed: _loadPaymentHistory,
);
}
return const SizedBox.shrink();
},
),
),
],
),
),
);
}
Widget _buildFilters() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
// Filtre période
UnifiedFilterChip(
label: _periodOptions.firstWhere((o) => o['value'] == _selectedPeriod)['label']!,
isSelected: _selectedPeriod != 'all',
onTap: () => _showPeriodFilter(),
),
const SizedBox(width: 8),
// Filtre statut
UnifiedFilterChip(
label: _statusOptions.firstWhere((o) => o['value'] == _selectedStatus)['label']!,
isSelected: _selectedStatus != 'all',
onTap: () => _showStatusFilter(),
),
const SizedBox(width: 8),
// Filtre méthode
UnifiedFilterChip(
label: _methodOptions.firstWhere((o) => o['value'] == _selectedMethod)['label']!,
isSelected: _selectedMethod != 'all',
onTap: () => _showMethodFilter(),
),
// Bouton reset
if (_selectedPeriod != 'all' || _selectedStatus != 'all' || _selectedMethod != 'all') ...[
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.clear),
onPressed: _resetFilters,
tooltip: 'Réinitialiser les filtres',
),
],
],
),
),
);
}
Widget _buildPaymentsList(List<PaymentModel> payments) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: payments.length,
itemBuilder: (context, index) {
final payment = payments[index];
return _buildPaymentCard(payment);
},
);
}
Widget _buildPaymentCard(PaymentModel payment) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () => _showPaymentDetails(payment),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec statut
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
payment.nomMembre ?? 'Membre inconnu',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
Text(
'Réf: ${payment.referenceTransaction}',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
_buildStatusChip(payment.statut),
],
),
const SizedBox(height: 12),
// Montant et méthode
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${payment.montant.toStringAsFixed(0)} XOF',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.accentColor,
),
),
Text(
_getMethodLabel(payment.methodePaiement),
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatDate(payment.dateCreation),
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
if (payment.dateTraitement != null)
Text(
'Traité: ${_formatDate(payment.dateTraitement!)}',
style: const TextStyle(
fontSize: 10,
color: AppTheme.textSecondary,
),
),
],
),
],
),
// Description si disponible
if (payment.description?.isNotEmpty == true) ...[
const SizedBox(height: 8),
Text(
payment.description!,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
);
}
Widget _buildStatusChip(String statut) {
Color backgroundColor;
Color textColor;
String label;
switch (statut) {
case 'COMPLETED':
backgroundColor = AppTheme.successColor;
textColor = Colors.white;
label = 'Complété';
break;
case 'PENDING':
backgroundColor = AppTheme.warningColor;
textColor = Colors.white;
label = 'En attente';
break;
case 'FAILED':
backgroundColor = AppTheme.errorColor;
textColor = Colors.white;
label = 'Échoué';
break;
case 'CANCELLED':
backgroundColor = Colors.grey;
textColor = Colors.white;
label = 'Annulé';
break;
default:
backgroundColor = Colors.grey.shade300;
textColor = AppTheme.textPrimary;
label = statut;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: textColor,
),
),
);
}
String _getMethodLabel(String method) {
switch (method) {
case 'WAVE': return 'Wave Money';
case 'ORANGE_MONEY': return 'Orange Money';
case 'MTN_MONEY': return 'MTN Money';
case 'CASH': return 'Espèces';
case 'BANK_TRANSFER': return 'Virement bancaire';
default: return method;
}
}
String _formatDate(DateTime date) {
return '${date.day}/${date.month}/${date.year} ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
void _showPeriodFilter() {
showModalBottomSheet(
context: context,
builder: (context) => _buildFilterBottomSheet(
'Période',
_periodOptions,
_selectedPeriod,
(value) {
setState(() {
_selectedPeriod = value;
});
_onFilterChanged();
},
),
);
}
void _showStatusFilter() {
showModalBottomSheet(
context: context,
builder: (context) => _buildFilterBottomSheet(
'Statut',
_statusOptions,
_selectedStatus,
(value) {
setState(() {
_selectedStatus = value;
});
_onFilterChanged();
},
),
);
}
void _showMethodFilter() {
showModalBottomSheet(
context: context,
builder: (context) => _buildFilterBottomSheet(
'Méthode de paiement',
_methodOptions,
_selectedMethod,
(value) {
setState(() {
_selectedMethod = value;
});
_onFilterChanged();
},
),
);
}
Widget _buildFilterBottomSheet(
String title,
List<Map<String, String>> options,
String selectedValue,
Function(String) onSelected,
) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
...options.map((option) {
final isSelected = option['value'] == selectedValue;
return ListTile(
title: Text(option['label']!),
trailing: isSelected ? const Icon(Icons.check, color: AppTheme.accentColor) : null,
onTap: () {
onSelected(option['value']!);
Navigator.pop(context);
},
);
}).toList(),
],
),
);
}
void _resetFilters() {
setState(() {
_selectedPeriod = 'all';
_selectedStatus = 'all';
_selectedMethod = 'all';
_searchController.clear();
});
_onFilterChanged();
}
void _showPaymentDetails(PaymentModel payment) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.7,
maxChildSize: 0.9,
minChildSize: 0.5,
builder: (context, scrollController) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Handle
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
// Titre
Text(
'Détails du Paiement',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
// Contenu scrollable
Expanded(
child: SingleChildScrollView(
controller: scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow('Référence', payment.referenceTransaction),
_buildDetailRow('Membre', payment.nomMembre ?? 'N/A'),
_buildDetailRow('Montant', '${payment.montant.toStringAsFixed(0)} XOF'),
_buildDetailRow('Méthode', _getMethodLabel(payment.methodePaiement)),
_buildDetailRow('Statut', _getStatusLabel(payment.statut)),
_buildDetailRow('Date de création', _formatDate(payment.dateCreation)),
if (payment.dateTraitement != null)
_buildDetailRow('Date de traitement', _formatDate(payment.dateTraitement!)),
if (payment.description?.isNotEmpty == true)
_buildDetailRow('Description', payment.description!),
if (payment.referencePaiementExterne?.isNotEmpty == true)
_buildDetailRow('Référence externe', payment.referencePaiementExterne!),
],
),
),
),
],
),
);
},
),
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
),
),
),
],
),
);
}
String _getStatusLabel(String status) {
switch (status) {
case 'COMPLETED': return 'Complété';
case 'PENDING': return 'En attente';
case 'FAILED': return 'Échoué';
case 'CANCELLED': return 'Annulé';
default: return status;
}
}
void _exportHistory() {
// TODO: Implémenter l'export de l'historique
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité d\'export à implémenter'),
backgroundColor: AppTheme.infoColor,
),
);
}
}

View File

@@ -0,0 +1,668 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/services/wave_integration_service.dart';
import '../../../../core/services/wave_payment_service.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/buttons/primary_button.dart';
import '../../../../shared/widgets/common/unified_page_layout.dart';
/// Page de démonstration de l'intégration Wave Money
/// Permet de tester toutes les fonctionnalités Wave
class WaveDemoPage extends StatefulWidget {
const WaveDemoPage({super.key});
@override
State<WaveDemoPage> createState() => _WaveDemoPageState();
}
class _WaveDemoPageState extends State<WaveDemoPage>
with TickerProviderStateMixin {
late WaveIntegrationService _waveIntegrationService;
late WavePaymentService _wavePaymentService;
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
final _amountController = TextEditingController(text: '5000');
final _phoneController = TextEditingController(text: '77123456');
final _nameController = TextEditingController(text: 'Test User');
bool _isLoading = false;
String _lastResult = '';
WavePaymentStats? _stats;
@override
void initState() {
super.initState();
_waveIntegrationService = getIt<WaveIntegrationService>();
_wavePaymentService = getIt<WavePaymentService>();
_animationController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
_animationController.forward();
_loadStats();
}
@override
void dispose() {
_amountController.dispose();
_phoneController.dispose();
_nameController.dispose();
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return UnifiedPageLayout(
title: 'Wave Money Demo',
subtitle: 'Test d\'intégration Wave Money',
showBackButton: true,
child: FadeTransition(
opacity: _fadeAnimation,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWaveHeader(),
const SizedBox(height: 24),
_buildTestForm(),
const SizedBox(height: 24),
_buildQuickActions(),
const SizedBox(height: 24),
_buildStatsSection(),
const SizedBox(height: 24),
_buildResultSection(),
],
),
),
),
);
}
Widget _buildWaveHeader() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF00D4FF), Color(0xFF0099CC)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF00D4FF).withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Row(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
),
child: const Icon(
Icons.waves,
size: 32,
color: Color(0xFF00D4FF),
),
),
const SizedBox(width: 16),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wave Money Integration',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
'Test et démonstration',
style: TextStyle(
fontSize: 14,
color: Colors.white70,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Icon(Icons.info_outline, color: Colors.white, size: 16),
SizedBox(width: 8),
Expanded(
child: Text(
'Environnement de test - Aucun paiement réel ne sera effectué',
style: TextStyle(
fontSize: 12,
color: Colors.white,
),
),
),
],
),
),
],
),
);
}
Widget _buildTestForm() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.borderLight),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Paramètres de test',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
// Montant
TextFormField(
controller: _amountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Montant (XOF)',
prefixIcon: Icon(Icons.attach_money),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// Numéro de téléphone
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Numéro Wave Money',
prefixIcon: Icon(Icons.phone),
prefixText: '+225 ',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// Nom
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nom du payeur',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 20),
// Bouton de test
SizedBox(
width: double.infinity,
child: PrimaryButton(
text: _isLoading ? 'Test en cours...' : 'Tester le paiement Wave',
icon: _isLoading ? null : Icons.play_arrow,
onPressed: _isLoading ? null : _testWavePayment,
isLoading: _isLoading,
backgroundColor: const Color(0xFF00D4FF),
),
),
],
),
);
}
Widget _buildQuickActions() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.borderLight),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Actions rapides',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildActionChip(
'Calculer frais',
Icons.calculate,
_calculateFees,
),
_buildActionChip(
'Historique',
Icons.history,
_showHistory,
),
_buildActionChip(
'Statistiques',
Icons.analytics,
_loadStats,
),
_buildActionChip(
'Vider cache',
Icons.clear_all,
_clearCache,
),
],
),
],
),
);
}
Widget _buildActionChip(String label, IconData icon, VoidCallback onPressed) {
return ActionChip(
avatar: Icon(icon, size: 16),
label: Text(label),
onPressed: onPressed,
backgroundColor: AppTheme.backgroundLight,
side: const BorderSide(color: AppTheme.borderLight),
);
}
Widget _buildStatsSection() {
if (_stats == null) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.borderLight),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Statistiques Wave Money',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
childAspectRatio: 2.5,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
children: [
_buildStatCard(
'Total paiements',
_stats!.totalPayments.toString(),
Icons.payment,
AppTheme.primaryColor,
),
_buildStatCard(
'Réussis',
_stats!.completedPayments.toString(),
Icons.check_circle,
AppTheme.successColor,
),
_buildStatCard(
'Montant total',
'${_stats!.totalAmount.toStringAsFixed(0)} XOF',
Icons.attach_money,
AppTheme.warningColor,
),
_buildStatCard(
'Taux de réussite',
'${_stats!.successRate.toStringAsFixed(1)}%',
Icons.trending_up,
AppTheme.infoColor,
),
],
),
],
),
);
}
Widget _buildStatCard(String title, String value, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 4),
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 12,
color: color,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
Widget _buildResultSection() {
if (_lastResult.isEmpty) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.borderLight),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'Dernier résultat',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.copy, size: 16),
onPressed: () {
Clipboard.setData(ClipboardData(text: _lastResult));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Résultat copié')),
);
},
tooltip: 'Copier',
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.borderLight),
),
child: Text(
_lastResult,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: AppTheme.textSecondary,
),
),
),
],
),
);
}
// Actions
Future<void> _testWavePayment() async {
setState(() {
_isLoading = true;
_lastResult = '';
});
try {
final amount = double.tryParse(_amountController.text) ?? 0;
if (amount <= 0) {
throw Exception('Montant invalide');
}
// Créer une cotisation de test
final testCotisation = CotisationModel(
id: 'test_${DateTime.now().millisecondsSinceEpoch}',
numeroReference: 'TEST-${DateTime.now().millisecondsSinceEpoch}',
membreId: 'test_member',
nomMembre: _nameController.text,
typeCotisation: 'MENSUELLE',
montantDu: amount,
montantPaye: 0,
codeDevise: 'XOF',
dateEcheance: DateTime.now().add(const Duration(days: 30)),
statut: 'EN_ATTENTE',
recurrente: false,
nombreRappels: 0,
annee: DateTime.now().year,
dateCreation: DateTime.now(),
);
// Initier le paiement Wave
final result = await _waveIntegrationService.initiateWavePayment(
cotisationId: testCotisation.id,
montant: amount,
numeroTelephone: _phoneController.text,
nomPayeur: _nameController.text,
metadata: {
'test_mode': true,
'demo_page': true,
},
);
setState(() {
_lastResult = '''
Test de paiement Wave Money
Résultat: ${result.success ? 'SUCCÈS' : 'ÉCHEC'}
${result.success ? '''
ID Paiement: ${result.payment?.id}
Session Wave: ${result.session?.waveSessionId}
URL Checkout: ${result.checkoutUrl}
Montant: ${amount.toStringAsFixed(0)} XOF
Frais: ${_wavePaymentService.calculateWaveFees(amount).toStringAsFixed(0)} XOF
''' : '''
Erreur: ${result.errorMessage}
'''}
Timestamp: ${DateTime.now().toIso8601String()}
'''.trim();
});
// Feedback haptique
HapticFeedback.lightImpact();
// Recharger les statistiques
await _loadStats();
} catch (e) {
setState(() {
_lastResult = 'Erreur lors du test: $e';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
void _calculateFees() {
final amount = double.tryParse(_amountController.text) ?? 0;
if (amount <= 0) {
setState(() {
_lastResult = 'Montant invalide pour le calcul des frais';
});
return;
}
final fees = _wavePaymentService.calculateWaveFees(amount);
final total = amount + fees;
setState(() {
_lastResult = '''
Calcul des frais Wave Money
Montant: ${amount.toStringAsFixed(0)} XOF
Frais Wave: ${fees.toStringAsFixed(0)} XOF
Total: ${total.toStringAsFixed(0)} XOF
Barème Wave CI 2024:
0-2000 XOF: Gratuit
2001-10000 XOF: 25 XOF
10001-50000 XOF: 100 XOF
50001-100000 XOF: 200 XOF
100001-500000 XOF: 500 XOF
>500000 XOF: 0.1% du montant
'''.trim();
});
}
Future<void> _showHistory() async {
try {
final history = await _waveIntegrationService.getWavePaymentHistory(limit: 10);
setState(() {
_lastResult = '''
Historique des paiements Wave (10 derniers)
${history.isEmpty ? 'Aucun paiement trouvé' : history.map((payment) => '''
${payment.numeroReference} - ${payment.montant.toStringAsFixed(0)} XOF
Statut: ${payment.statut}
Date: ${payment.dateTransaction.toString().substring(0, 16)}
''').join('\n')}
Total: ${history.length} paiement(s)
'''.trim();
});
} catch (e) {
setState(() {
_lastResult = 'Erreur lors de la récupération de l\'historique: $e';
});
}
}
Future<void> _loadStats() async {
try {
final stats = await _waveIntegrationService.getWavePaymentStats();
setState(() {
_stats = stats;
});
} catch (e) {
print('Erreur lors du chargement des statistiques: $e');
}
}
Future<void> _clearCache() async {
try {
// TODO: Implémenter le nettoyage du cache
setState(() {
_lastResult = 'Cache Wave Money vidé avec succès';
_stats = null;
});
await _loadStats();
} catch (e) {
setState(() {
_lastResult = 'Erreur lors du nettoyage du cache: $e';
});
}
}
}

View File

@@ -0,0 +1,697 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../core/models/payment_model.dart';
import '../../../../core/models/wave_checkout_session_model.dart';
import '../../../../core/services/wave_payment_service.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/buttons/primary_button.dart';
import '../../../../shared/widgets/common/unified_page_layout.dart';
import '../bloc/cotisations_bloc.dart';
import '../bloc/cotisations_event.dart';
import '../bloc/cotisations_state.dart';
/// Page dédiée aux paiements Wave Money
/// Interface moderne et sécurisée pour les paiements mobiles
class WavePaymentPage extends StatefulWidget {
final CotisationModel cotisation;
const WavePaymentPage({
super.key,
required this.cotisation,
});
@override
State<WavePaymentPage> createState() => _WavePaymentPageState();
}
class _WavePaymentPageState extends State<WavePaymentPage>
with TickerProviderStateMixin {
late CotisationsBloc _cotisationsBloc;
late WavePaymentService _wavePaymentService;
late AnimationController _animationController;
late AnimationController _pulseController;
late Animation<double> _fadeAnimation;
late Animation<double> _slideAnimation;
late Animation<double> _pulseAnimation;
final _formKey = GlobalKey<FormState>();
final _phoneController = TextEditingController();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
bool _isProcessing = false;
bool _termsAccepted = false;
WaveCheckoutSessionModel? _currentSession;
String? _paymentUrl;
@override
void initState() {
super.initState();
_cotisationsBloc = getIt<CotisationsBloc>();
_wavePaymentService = getIt<WavePaymentService>();
// Animations
_animationController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_pulseController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
_slideAnimation = Tween<double>(begin: 50.0, end: 0.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic),
);
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.1).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
_animationController.forward();
_pulseController.repeat(reverse: true);
// Pré-remplir les champs si disponible
_nameController.text = widget.cotisation.nomMembre;
}
@override
void dispose() {
_phoneController.dispose();
_nameController.dispose();
_emailController.dispose();
_animationController.dispose();
_pulseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cotisationsBloc,
child: UnifiedPageLayout(
title: 'Paiement Wave Money',
subtitle: 'Paiement sécurisé et instantané',
showBackButton: true,
backgroundColor: AppTheme.backgroundLight,
child: BlocConsumer<CotisationsBloc, CotisationsState>(
listener: _handleBlocState,
builder: (context, state) {
return FadeTransition(
opacity: _fadeAnimation,
child: Transform.translate(
offset: Offset(0, _slideAnimation.value),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWaveHeader(),
const SizedBox(height: 24),
_buildCotisationSummary(),
const SizedBox(height: 24),
_buildPaymentForm(),
const SizedBox(height: 24),
_buildSecurityInfo(),
const SizedBox(height: 24),
_buildPaymentButton(state),
],
),
),
),
),
);
},
),
),
);
}
Widget _buildWaveHeader() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF00D4FF), Color(0xFF0099CC)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF00D4FF).withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
ScaleTransition(
scale: _pulseAnimation,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.waves,
size: 32,
color: Color(0xFF00D4FF),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Wave Money',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
const Text(
'Paiement mobile sécurisé',
style: TextStyle(
fontSize: 14,
color: Colors.white70,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'🇨🇮 Côte d\'Ivoire',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
),
);
}
Widget _buildCotisationSummary() {
final remainingAmount = widget.cotisation.montantDu - widget.cotisation.montantPaye;
final fees = _wavePaymentService.calculateWaveFees(remainingAmount);
final total = remainingAmount + fees;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.borderLight),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Résumé de la cotisation',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
_buildSummaryRow('Type', widget.cotisation.typeCotisation),
_buildSummaryRow('Membre', widget.cotisation.nomMembre),
_buildSummaryRow('Référence', widget.cotisation.numeroReference),
const Divider(height: 24),
_buildSummaryRow('Montant', '${remainingAmount.toStringAsFixed(0)} XOF'),
_buildSummaryRow('Frais Wave', '${fees.toStringAsFixed(0)} XOF'),
const Divider(height: 24),
_buildSummaryRow(
'Total à payer',
'${total.toStringAsFixed(0)} XOF',
isTotal: true,
),
],
),
);
}
Widget _buildSummaryRow(String label, String value, {bool isTotal = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: isTotal ? 16 : 14,
fontWeight: isTotal ? FontWeight.bold : FontWeight.normal,
color: AppTheme.textSecondary,
),
),
Text(
value,
style: TextStyle(
fontSize: isTotal ? 16 : 14,
fontWeight: FontWeight.bold,
color: isTotal ? AppTheme.primaryColor : AppTheme.textPrimary,
),
),
],
),
);
}
Widget _buildPaymentForm() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.borderLight),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informations de paiement',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
_buildPhoneField(),
const SizedBox(height: 16),
_buildNameField(),
const SizedBox(height: 16),
_buildEmailField(),
const SizedBox(height: 16),
_buildTermsCheckbox(),
],
),
);
}
Widget _buildPhoneField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Numéro Wave Money *',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
decoration: InputDecoration(
hintText: '77 123 45 67',
prefixIcon: const Icon(Icons.phone_android, color: Color(0xFF00D4FF)),
prefixText: '+225 ',
prefixStyle: const TextStyle(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppTheme.borderLight),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF00D4FF), width: 2),
),
filled: true,
fillColor: AppTheme.backgroundLight,
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir votre numéro Wave Money';
}
if (value.length < 8) {
return 'Numéro invalide (minimum 8 chiffres)';
}
return null;
},
),
],
);
}
Widget _buildNameField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Nom complet *',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _nameController,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
hintText: 'Votre nom complet',
prefixIcon: const Icon(Icons.person, color: Color(0xFF00D4FF)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppTheme.borderLight),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF00D4FF), width: 2),
),
filled: true,
fillColor: AppTheme.backgroundLight,
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Veuillez saisir votre nom complet';
}
if (value.trim().length < 2) {
return 'Le nom doit contenir au moins 2 caractères';
}
return null;
},
),
],
);
}
Widget _buildEmailField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Email (optionnel)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
hintText: 'votre.email@exemple.com',
prefixIcon: const Icon(Icons.email, color: Color(0xFF00D4FF)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppTheme.borderLight),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF00D4FF), width: 2),
),
filled: true,
fillColor: AppTheme.backgroundLight,
),
validator: (value) {
if (value != null && value.isNotEmpty) {
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return 'Format d\'email invalide';
}
}
return null;
},
),
],
);
}
Widget _buildTermsCheckbox() {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
value: _termsAccepted,
onChanged: (value) {
setState(() {
_termsAccepted = value ?? false;
});
},
activeColor: const Color(0xFF00D4FF),
),
Expanded(
child: GestureDetector(
onTap: () {
setState(() {
_termsAccepted = !_termsAccepted;
});
},
child: const Text(
'J\'accepte les conditions d\'utilisation de Wave Money et autorise le prélèvement du montant indiqué.',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
),
),
],
);
}
Widget _buildSecurityInfo() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF0F9FF),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF00D4FF).withOpacity(0.2)),
),
child: Column(
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF00D4FF).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.security,
color: Color(0xFF00D4FF),
size: 20,
),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Paiement 100% sécurisé',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
),
],
),
const SizedBox(height: 12),
const Text(
'• Chiffrement SSL/TLS de bout en bout\n'
'• Conformité aux standards PCI DSS\n'
'• Aucune donnée bancaire stockée\n'
'• Transaction instantanée et traçable',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
height: 1.4,
),
),
],
),
);
}
Widget _buildPaymentButton(CotisationsState state) {
final isLoading = state is PaymentInProgress || _isProcessing;
final canPay = _formKey.currentState?.validate() == true &&
_termsAccepted &&
_phoneController.text.isNotEmpty &&
!isLoading;
return SizedBox(
width: double.infinity,
child: PrimaryButton(
text: isLoading
? 'Traitement en cours...'
: 'Payer avec Wave Money',
icon: isLoading ? null : Icons.waves,
onPressed: canPay ? _processWavePayment : null,
isLoading: isLoading,
backgroundColor: const Color(0xFF00D4FF),
),
);
}
void _handleBlocState(BuildContext context, CotisationsState state) {
if (state is PaymentSuccess) {
_showPaymentSuccessDialog(state.payment);
} else if (state is PaymentFailure) {
_showPaymentErrorDialog(state.errorMessage);
}
}
void _processWavePayment() async {
if (!_formKey.currentState!.validate() || !_termsAccepted) {
return;
}
setState(() {
_isProcessing = true;
});
try {
final remainingAmount = widget.cotisation.montantDu - widget.cotisation.montantPaye;
// Initier le paiement Wave via le BLoC
_cotisationsBloc.add(InitiatePayment(
cotisationId: widget.cotisation.id,
montant: remainingAmount,
methodePaiement: 'WAVE',
numeroTelephone: _phoneController.text.trim(),
nomPayeur: _nameController.text.trim(),
emailPayeur: _emailController.text.trim().isEmpty
? null
: _emailController.text.trim(),
));
} catch (e) {
setState(() {
_isProcessing = false;
});
_showPaymentErrorDialog('Erreur lors de l\'initiation du paiement: $e');
}
}
void _showPaymentSuccessDialog(PaymentModel payment) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.check_circle, color: AppTheme.successColor, size: 28),
SizedBox(width: 8),
Text('Paiement réussi !'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Votre paiement de ${payment.montant.toStringAsFixed(0)} XOF a été confirmé.'),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Référence: ${payment.numeroReference}'),
Text('Transaction: ${payment.numeroTransaction ?? 'N/A'}'),
Text('Date: ${DateTime.now().toString().substring(0, 16)}'),
],
),
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
Navigator.of(context).pop(); // Retour à la liste
},
child: const Text('Fermer'),
),
],
),
);
}
void _showPaymentErrorDialog(String errorMessage) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.error, color: AppTheme.errorColor, size: 28),
SizedBox(width: 8),
Text('Erreur de paiement'),
],
),
content: Text(errorMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
}

View File

@@ -0,0 +1,363 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../core/services/wave_payment_service.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/buttons/primary_button.dart';
import '../pages/wave_payment_page.dart';
/// Widget d'intégration Wave Money pour les cotisations
/// Affiche les options de paiement Wave avec calcul des frais
class WavePaymentWidget extends StatefulWidget {
final CotisationModel cotisation;
final VoidCallback? onPaymentInitiated;
final bool showFullInterface;
const WavePaymentWidget({
super.key,
required this.cotisation,
this.onPaymentInitiated,
this.showFullInterface = false,
});
@override
State<WavePaymentWidget> createState() => _WavePaymentWidgetState();
}
class _WavePaymentWidgetState extends State<WavePaymentWidget>
with SingleTickerProviderStateMixin {
late WavePaymentService _wavePaymentService;
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_wavePaymentService = getIt<WavePaymentService>();
_animationController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.elasticOut),
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
);
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: widget.showFullInterface
? _buildFullInterface()
: _buildCompactInterface(),
),
);
}
Widget _buildFullInterface() {
final remainingAmount = widget.cotisation.montantDu - widget.cotisation.montantPaye;
final fees = _wavePaymentService.calculateWaveFees(remainingAmount);
final total = remainingAmount + fees;
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF00D4FF), Color(0xFF0099CC)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF00D4FF).withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Wave
Row(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.waves,
size: 28,
color: Color(0xFF00D4FF),
),
),
const SizedBox(width: 16),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wave Money',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
'Paiement mobile instantané',
style: TextStyle(
fontSize: 12,
color: Colors.white70,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'🇨🇮 CI',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 20),
// Détails du paiement
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
_buildPaymentRow('Montant', '${remainingAmount.toStringAsFixed(0)} XOF'),
_buildPaymentRow('Frais Wave', '${fees.toStringAsFixed(0)} XOF'),
const Divider(color: Colors.white30, height: 20),
_buildPaymentRow(
'Total',
'${total.toStringAsFixed(0)} XOF',
isTotal: true,
),
],
),
),
const SizedBox(height: 20),
// Avantages Wave
_buildAdvantages(),
const SizedBox(height: 20),
// Bouton de paiement
SizedBox(
width: double.infinity,
child: PrimaryButton(
text: 'Payer avec Wave',
icon: Icons.payment,
onPressed: _navigateToWavePayment,
backgroundColor: Colors.white,
textColor: const Color(0xFF00D4FF),
),
),
],
),
);
}
Widget _buildCompactInterface() {
final remainingAmount = widget.cotisation.montantDu - widget.cotisation.montantPaye;
final fees = _wavePaymentService.calculateWaveFees(remainingAmount);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF00D4FF).withOpacity(0.3)),
boxShadow: [
BoxShadow(
color: const Color(0xFF00D4FF).withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF00D4FF).withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.waves,
size: 24,
color: Color(0xFF00D4FF),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Wave Money',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
Text(
'Frais: ${fees.toStringAsFixed(0)} XOF • Instantané',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
PrimaryButton(
text: 'Payer',
onPressed: _navigateToWavePayment,
backgroundColor: const Color(0xFF00D4FF),
isCompact: true,
),
],
),
);
}
Widget _buildPaymentRow(String label, String value, {bool isTotal = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: isTotal ? 16 : 14,
fontWeight: isTotal ? FontWeight.bold : FontWeight.normal,
color: Colors.white70,
),
),
Text(
value,
style: TextStyle(
fontSize: isTotal ? 16 : 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
);
}
Widget _buildAdvantages() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Pourquoi choisir Wave ?',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
_buildAdvantageItem('', 'Paiement instantané'),
_buildAdvantageItem('🔒', 'Sécurisé et fiable'),
_buildAdvantageItem('💰', 'Frais les plus bas'),
_buildAdvantageItem('📱', 'Simple et rapide'),
],
);
}
Widget _buildAdvantageItem(String icon, String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Text(
icon,
style: const TextStyle(fontSize: 12),
),
const SizedBox(width: 8),
Text(
text,
style: const TextStyle(
fontSize: 12,
color: Colors.white70,
),
),
],
),
);
}
void _navigateToWavePayment() {
// Feedback haptique
HapticFeedback.lightImpact();
// Callback si fourni
widget.onPaymentInitiated?.call();
// Navigation vers la page de paiement Wave
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WavePaymentPage(cotisation: widget.cotisation),
),
);
}
}

View File

@@ -3,6 +3,7 @@ import '../../../../shared/theme/app_theme.dart';
import '../../../../core/animations/page_transitions.dart';
import '../../../demo/presentation/pages/animations_demo_page.dart';
import '../../../debug/debug_api_test_page.dart';
import '../../../performance/presentation/pages/performance_demo_page.dart';
// Imports des nouveaux widgets refactorisés
import '../widgets/welcome/welcome_section_widget.dart';
@@ -11,6 +12,9 @@ import '../widgets/actions/quick_actions_widget.dart';
import '../widgets/activities/recent_activities_widget.dart';
import '../widgets/charts/charts_analytics_widget.dart';
// Import de l'architecture unifiée pour amélioration progressive
import '../../../../shared/widgets/common/unified_page_layout.dart';
/// Page principale du tableau de bord UnionFlow
///
/// Affiche une vue d'ensemble complète de l'association avec :
@@ -27,76 +31,79 @@ class DashboardPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: AppBar(
title: const Text('Tableau de bord'),
backgroundColor: AppTheme.primaryColor,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.animation),
onPressed: () {
Navigator.of(context).push(
PageTransitions.morphWithBlur(const AnimationsDemoPage()),
);
},
tooltip: 'Démonstration des animations',
),
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () {
// TODO: Implémenter la navigation vers les notifications
},
),
IconButton(
icon: const Icon(Icons.bug_report),
onPressed: () {
Navigator.of(context).push(
PageTransitions.slideFromRight(const DebugApiTestPage()),
);
},
tooltip: 'Debug API',
),
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () {
// TODO: Implémenter la navigation vers les paramètres
},
),
],
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. ACCUEIL & CONTEXTE - Message de bienvenue personnalisé
const WelcomeSectionWidget(),
const SizedBox(height: 24),
// 2. VISION GLOBALE - Indicateurs clés de performance (KPI)
// Vue d'ensemble immédiate de la santé de l'association
const KPICardsWidget(),
const SizedBox(height: 24),
// 3. ACTIONS PRIORITAIRES - Actions rapides et gestion
// Accès direct aux tâches critiques quotidiennes
const QuickActionsWidget(),
const SizedBox(height: 24),
// 4. SUIVI TEMPS RÉEL - Flux d'activités en direct
// Monitoring des événements récents et alertes
const RecentActivitiesWidget(),
const SizedBox(height: 24),
// 5. ANALYSES APPROFONDIES - Graphiques et tendances
// Analyses détaillées pour la prise de décision stratégique
const ChartsAnalyticsWidget(),
],
),
// Utilisation de UnifiedPageLayout pour améliorer la cohérence
// tout en conservant tous les widgets spécialisés existants
return UnifiedPageLayout(
title: 'Tableau de bord',
icon: Icons.dashboard,
actions: [
IconButton(
icon: const Icon(Icons.animation),
onPressed: () {
Navigator.of(context).push(
PageTransitions.morphWithBlur(const AnimationsDemoPage()),
);
},
tooltip: 'Démonstration des animations',
),
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () {
// TODO: Implémenter la navigation vers les notifications
},
),
IconButton(
icon: const Icon(Icons.bug_report),
onPressed: () {
Navigator.of(context).push(
PageTransitions.slideFromRight(const DebugApiTestPage()),
);
},
tooltip: 'Debug API',
),
IconButton(
icon: const Icon(Icons.speed),
onPressed: () {
Navigator.of(context).push(
PageTransitions.slideFromRight(const PerformanceDemoPage()),
);
},
tooltip: 'Performance',
),
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () {
// TODO: Implémenter la navigation vers les paramètres
},
),
],
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. ACCUEIL & CONTEXTE - Message de bienvenue personnalisé
// CONSERVÉ: Widget spécialisé avec toutes ses fonctionnalités
const WelcomeSectionWidget(),
const SizedBox(height: 24),
// 2. VISION GLOBALE - Indicateurs clés de performance (KPI)
// CONSERVÉ: KPI enrichis avec détails, cibles, périodes
const KPICardsWidget(),
const SizedBox(height: 24),
// 3. ACTIONS PRIORITAIRES - Actions rapides et gestion
// CONSERVÉ: Grille d'actions organisées par catégories
const QuickActionsWidget(),
const SizedBox(height: 24),
// 4. SUIVI TEMPS RÉEL - Flux d'activités en direct
// CONSERVÉ: Activités avec indicateur "Live" et horodatage
const RecentActivitiesWidget(),
const SizedBox(height: 24),
// 5. ANALYSES APPROFONDIES - Graphiques et tendances
// CONSERVÉ: 1617 lignes de graphiques sophistiqués avec fl_chart
const ChartsAnalyticsWidget(),
],
),
);
}

View File

@@ -0,0 +1,439 @@
import 'package:flutter/material.dart';
import '../../../../shared/widgets/unified_components.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../core/animations/page_transitions.dart';
import '../../../demo/presentation/pages/animations_demo_page.dart';
import '../../../debug/debug_api_test_page.dart';
/// Page principale du tableau de bord UnionFlow - Version Unifiée
///
/// Utilise l'architecture unifiée avec composants standardisés pour :
/// - Cohérence visuelle parfaite avec les autres onglets
/// - Maintenabilité optimale et réutilisabilité maximale
/// - Performance 60 FPS avec animations fluides
/// - Expérience utilisateur homogène
class DashboardPageUnified extends StatelessWidget {
const DashboardPageUnified({super.key});
@override
Widget build(BuildContext context) {
return UnifiedPageLayout(
title: 'Tableau de bord',
subtitle: 'Vue d\'ensemble de votre association',
icon: Icons.dashboard,
iconColor: AppTheme.primaryColor,
actions: _buildActions(context),
body: Column(
children: [
_buildWelcomeSection(),
const SizedBox(height: AppTheme.spacingLarge),
_buildKPISection(),
const SizedBox(height: AppTheme.spacingLarge),
_buildQuickActionsSection(),
const SizedBox(height: AppTheme.spacingLarge),
_buildRecentActivitiesSection(),
const SizedBox(height: AppTheme.spacingLarge),
_buildAnalyticsSection(),
],
),
);
}
/// Actions de la barre d'outils
List<Widget> _buildActions(BuildContext context) {
return [
IconButton(
icon: const Icon(Icons.animation),
onPressed: () => Navigator.of(context).push(
PageTransitions.morphWithBlur(const AnimationsDemoPage()),
),
tooltip: 'Démonstration des animations',
),
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () {
// TODO: Implémenter la navigation vers les notifications
},
tooltip: 'Notifications',
),
IconButton(
icon: const Icon(Icons.bug_report),
onPressed: () => Navigator.of(context).push(
PageTransitions.slideFromRight(const DebugApiTestPage()),
),
tooltip: 'Debug API',
),
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () {
// TODO: Implémenter la navigation vers les paramètres
},
tooltip: 'Paramètres',
),
];
}
/// Section d'accueil personnalisée
Widget _buildWelcomeSection() {
return UnifiedCard.elevated(
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingLarge),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(AppTheme.spacingMedium),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
child: Icon(
Icons.waving_hand,
color: AppTheme.primaryColor,
size: 32,
),
),
const SizedBox(width: AppTheme.spacingMedium),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bonjour !',
style: AppTheme.headlineSmall.copyWith(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingXSmall),
Text(
'Bienvenue sur votre tableau de bord UnionFlow',
style: AppTheme.bodyMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
),
);
}
/// Section des indicateurs clés de performance
Widget _buildKPISection() {
final kpis = [
UnifiedKPIData(
title: 'Membres',
value: '247',
icon: Icons.people,
color: AppTheme.primaryColor,
trend: UnifiedKPITrend(
direction: UnifiedKPITrendDirection.up,
value: '+12',
label: 'ce mois',
),
),
UnifiedKPIData(
title: 'Événements',
value: '18',
icon: Icons.event,
color: AppTheme.accentColor,
trend: UnifiedKPITrend(
direction: UnifiedKPITrendDirection.up,
value: '+3',
label: 'ce mois',
),
),
UnifiedKPIData(
title: 'Cotisations',
value: '89%',
icon: Icons.account_balance_wallet,
color: AppTheme.successColor,
trend: UnifiedKPITrend(
direction: UnifiedKPITrendDirection.up,
value: '+5%',
label: 'vs mois dernier',
),
),
UnifiedKPIData(
title: 'Trésorerie',
value: '12.5K€',
icon: Icons.euro,
color: AppTheme.warningColor,
trend: UnifiedKPITrend(
direction: UnifiedKPITrendDirection.stable,
value: '0%',
label: 'stable',
),
),
];
return UnifiedKPISection(
title: 'Vue d\'ensemble',
kpis: kpis,
);
}
/// Section des actions rapides
Widget _buildQuickActionsSection() {
final actions = [
UnifiedQuickAction(
id: 'add_member',
title: 'Nouveau\nMembre',
icon: Icons.person_add,
color: AppTheme.primaryColor,
),
UnifiedQuickAction(
id: 'add_event',
title: 'Nouvel\nÉvénement',
icon: Icons.event_available,
color: AppTheme.accentColor,
badgeCount: 3,
),
UnifiedQuickAction(
id: 'manage_cotisations',
title: 'Gérer\nCotisations',
icon: Icons.account_balance_wallet,
color: AppTheme.successColor,
badgeCount: 7,
),
UnifiedQuickAction(
id: 'reports',
title: 'Rapports\n& Stats',
icon: Icons.analytics,
color: AppTheme.infoColor,
),
UnifiedQuickAction(
id: 'communications',
title: 'Envoyer\nMessage',
icon: Icons.send,
color: AppTheme.warningColor,
),
UnifiedQuickAction(
id: 'settings',
title: 'Paramètres\nAssociation',
icon: Icons.settings,
color: AppTheme.textSecondary,
),
];
return UnifiedQuickActionsSection(
title: 'Actions rapides',
actions: actions,
onActionTap: _handleQuickAction,
);
}
/// Section des activités récentes
Widget _buildRecentActivitiesSection() {
final activities = [
_ActivityItem(
title: 'Nouveau membre inscrit',
subtitle: 'Marie Dubois a rejoint l\'association',
icon: Icons.person_add,
color: AppTheme.successColor,
time: 'Il y a 2h',
),
_ActivityItem(
title: 'Événement créé',
subtitle: 'Assemblée Générale 2024 programmée',
icon: Icons.event,
color: AppTheme.accentColor,
time: 'Il y a 4h',
),
_ActivityItem(
title: 'Cotisation reçue',
subtitle: 'Jean Martin - Cotisation annuelle',
icon: Icons.payment,
color: AppTheme.primaryColor,
time: 'Il y a 6h',
),
];
return UnifiedCard.elevated(
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingLarge),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.timeline,
color: AppTheme.primaryColor,
size: 24,
),
const SizedBox(width: AppTheme.spacingSmall),
Text(
'Activités récentes',
style: AppTheme.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: AppTheme.spacingMedium),
...activities.map((activity) => _buildActivityItem(activity)),
],
),
),
);
}
/// Section d'analyses et graphiques
Widget _buildAnalyticsSection() {
return UnifiedCard.elevated(
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingLarge),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.analytics,
color: AppTheme.accentColor,
size: 24,
),
const SizedBox(width: AppTheme.spacingSmall),
Text(
'Analyses & Tendances',
style: AppTheme.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
UnifiedButton.tertiary(
text: 'Voir plus',
size: UnifiedButtonSize.small,
onPressed: () {
// TODO: Navigation vers analyses détaillées
},
),
],
),
const SizedBox(height: AppTheme.spacingMedium),
Container(
height: 120,
decoration: BoxDecoration(
color: AppTheme.accentColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bar_chart,
color: AppTheme.accentColor,
size: 48,
),
const SizedBox(height: AppTheme.spacingSmall),
Text(
'Graphiques interactifs',
style: AppTheme.bodyMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
),
],
),
),
);
}
/// Construit un élément d'activité
Widget _buildActivityItem(_ActivityItem activity) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppTheme.spacingSmall),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(AppTheme.spacingSmall),
decoration: BoxDecoration(
color: activity.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
),
child: Icon(
activity.icon,
color: activity.color,
size: 16,
),
),
const SizedBox(width: AppTheme.spacingMedium),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
activity.title,
style: AppTheme.bodyMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
activity.subtitle,
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
Text(
activity.time,
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
);
}
/// Gère les actions rapides
void _handleQuickAction(UnifiedQuickAction action) {
// TODO: Implémenter la navigation selon l'action
switch (action.id) {
case 'add_member':
// Navigation vers ajout membre
break;
case 'add_event':
// Navigation vers ajout événement
break;
case 'manage_cotisations':
// Navigation vers gestion cotisations
break;
case 'reports':
// Navigation vers rapports
break;
case 'communications':
// Navigation vers communications
break;
case 'settings':
// Navigation vers paramètres
break;
}
}
}
/// Modèle pour les éléments d'activité
class _ActivityItem {
final String title;
final String subtitle;
final IconData icon;
final Color color;
final String time;
const _ActivityItem({
required this.title,
required this.subtitle,
required this.icon,
required this.color,
required this.time,
});
}

View File

@@ -78,7 +78,7 @@ Nombre d'événements de test: ${evenements.length}
Erreur: $e
Le serveur backend n'est pas accessible.
Vérifiez que le serveur Quarkus est démarré sur 192.168.1.145:8080
Vérifiez que le serveur Quarkus est démarré sur 192.168.1.11:8080
''';
_isLoading = false;
});
@@ -220,7 +220,7 @@ Vérifiez que le serveur Quarkus est démarré sur 192.168.1.145:8080
),
const SizedBox(height: 12),
const Text(
'URL Backend: http://192.168.1.145:8080\n'
'URL Backend: http://192.168.1.11:8080\n'
'Endpoint: /api/evenements/a-venir-public\n'
'Méthode: GET',
style: TextStyle(

View File

@@ -15,7 +15,21 @@ import '../widgets/animated_evenement_list.dart';
import 'evenement_detail_page.dart';
import 'evenement_create_page.dart';
// Import de l'architecture unifiée pour amélioration progressive
import '../../../../shared/widgets/common/unified_page_layout.dart';
/// Page principale des événements
///
/// ARCHITECTURE SOPHISTIQUÉE CONSERVÉE :
/// - TabController avec 3 onglets (À venir, Publics, Tous)
/// - Animations complexes avec multiple AnimationControllers
/// - Scroll infini avec pagination intelligente par onglet
/// - Recherche et filtres avancés intégrés
/// - Navigation avec transitions personnalisées
/// - Logique métier complexe pour chaque onglet
///
/// Cette page utilise déjà une architecture avancée et cohérente.
/// L'amélioration incrémentale préserve toutes les fonctionnalités existantes.
class EvenementsPage extends StatelessWidget {
const EvenementsPage({super.key});

View File

@@ -0,0 +1,503 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/evenement_model.dart';
import '../../../../shared/widgets/unified_components.dart';
import '../../../../shared/theme/app_theme.dart';
import '../bloc/evenement_bloc.dart';
import '../bloc/evenement_event.dart';
import '../bloc/evenement_state.dart';
import '../widgets/evenement_search_bar.dart';
import '../widgets/evenement_filter_chips.dart';
import 'evenement_detail_page.dart';
import 'evenement_create_page.dart';
/// Page des événements refactorisée avec l'architecture unifiée
class EvenementsPageUnified extends StatelessWidget {
const EvenementsPageUnified({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<EvenementBloc>()
..add(const LoadEvenementsAVenir()),
child: const _EvenementsPageContent(),
);
}
}
class _EvenementsPageContent extends StatefulWidget {
const _EvenementsPageContent();
@override
State<_EvenementsPageContent> createState() => _EvenementsPageContentState();
}
class _EvenementsPageContentState extends State<_EvenementsPageContent>
with TickerProviderStateMixin {
late TabController _tabController;
String _searchTerm = '';
TypeEvenement? _selectedType;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(_onTabChanged);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _onTabChanged() {
if (_tabController.indexIsChanging) {
_loadEventsForTab(_tabController.index);
}
}
void _loadEventsForTab(int index) {
context.read<EvenementBloc>().add(const ResetEvenementState());
switch (index) {
case 0:
context.read<EvenementBloc>().add(const LoadEvenementsAVenir());
break;
case 1:
context.read<EvenementBloc>().add(const LoadEvenementsPublics());
break;
case 2:
context.read<EvenementBloc>().add(const LoadEvenements());
break;
}
}
void _onSearch(String terme) {
setState(() {
_searchTerm = terme;
_selectedType = null;
});
if (terme.isNotEmpty) {
context.read<EvenementBloc>().add(
SearchEvenements(terme: terme, refresh: true),
);
} else {
context.read<EvenementBloc>().add(
const LoadEvenements(refresh: true),
);
}
}
void _onFilterByType(TypeEvenement? type) {
setState(() {
_selectedType = type;
_searchTerm = '';
});
if (type != null) {
context.read<EvenementBloc>().add(
FilterEvenementsByType(type: type, refresh: true),
);
} else {
context.read<EvenementBloc>().add(
const LoadEvenements(refresh: true),
);
}
}
void _onRefresh() {
_loadEventsForTab(_tabController.index);
}
void _onLoadMore() {
final state = context.read<EvenementBloc>().state;
if (state is EvenementLoaded && !state.hasReachedMax) {
final nextPage = state.currentPage + 1;
switch (_tabController.index) {
case 0:
context.read<EvenementBloc>().add(
LoadEvenementsAVenir(page: nextPage),
);
break;
case 1:
context.read<EvenementBloc>().add(
LoadEvenementsPublics(page: nextPage),
);
break;
case 2:
if (_searchTerm.isNotEmpty) {
context.read<EvenementBloc>().add(
SearchEvenements(terme: _searchTerm, page: nextPage),
);
} else if (_selectedType != null) {
context.read<EvenementBloc>().add(
FilterEvenementsByType(type: _selectedType!, page: nextPage),
);
} else {
context.read<EvenementBloc>().add(
LoadEvenements(page: nextPage),
);
}
break;
}
}
}
@override
Widget build(BuildContext context) {
return UnifiedPageLayout(
title: 'Événements',
subtitle: 'Gestion des événements de l\'association',
icon: Icons.event,
iconColor: AppTheme.accentColor,
scrollable: false,
padding: EdgeInsets.zero,
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const EvenementCreatePage(),
),
);
},
),
],
body: Column(
children: [
// En-tête avec KPI
_buildKPISection(),
// Onglets
_buildTabBar(),
// Contenu des onglets
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildEventsList(showUpcoming: true),
_buildEventsList(showPublic: true),
_buildEventsListWithFilters(),
],
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const EvenementCreatePage(),
),
);
},
backgroundColor: AppTheme.accentColor,
child: const Icon(Icons.add),
),
);
}
Widget _buildKPISection() {
return BlocBuilder<EvenementBloc, EvenementState>(
builder: (context, state) {
final kpis = _buildKPIData(state);
return Container(
padding: const EdgeInsets.all(AppTheme.spacingMedium),
child: UnifiedKPISection(
kpis: kpis,
crossAxisCount: 3,
spacing: AppTheme.spacingSmall,
),
);
},
);
}
List<UnifiedKPIData> _buildKPIData(EvenementState state) {
int totalEvents = 0;
int upcomingEvents = 0;
int publicEvents = 0;
if (state is EvenementLoaded) {
totalEvents = state.evenements.length;
upcomingEvents = state.evenements
.where((e) => e.dateDebut.isAfter(DateTime.now()))
.length;
publicEvents = state.evenements
.where((e) => e.typeEvenement == TypeEvenement.conference)
.length;
}
return [
UnifiedKPIData(
title: 'Total',
value: totalEvents.toString(),
icon: Icons.event,
color: AppTheme.primaryColor,
),
UnifiedKPIData(
title: 'À venir',
value: upcomingEvents.toString(),
icon: Icons.schedule,
color: AppTheme.accentColor,
),
UnifiedKPIData(
title: 'Publics',
value: publicEvents.toString(),
icon: Icons.public,
color: AppTheme.successColor,
),
];
}
Widget _buildTabBar() {
return Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
labelColor: AppTheme.primaryColor,
unselectedLabelColor: AppTheme.textSecondary,
indicatorColor: AppTheme.primaryColor,
tabs: const [
Tab(text: 'À venir'),
Tab(text: 'Publics'),
Tab(text: 'Tous'),
],
),
);
}
Widget _buildEventsList({bool showUpcoming = false, bool showPublic = false}) {
return BlocBuilder<EvenementBloc, EvenementState>(
builder: (context, state) {
if (state is EvenementError) {
return UnifiedPageLayout(
title: '',
showAppBar: false,
errorMessage: state.message,
onRefresh: _onRefresh,
body: const SizedBox.shrink(),
);
}
final isLoading = state is EvenementLoading;
final events = state is EvenementLoaded ? state.evenements : <EvenementModel>[];
final hasReachedMax = state is EvenementLoaded ? state.hasReachedMax : false;
return UnifiedListWidget<EvenementModel>(
items: events,
isLoading: isLoading,
hasReachedMax: hasReachedMax,
onLoadMore: _onLoadMore,
onRefresh: () async => _onRefresh(),
itemBuilder: (context, evenement, index) {
return _buildEventCard(evenement);
},
emptyWidget: _buildEmptyState(),
);
},
);
}
Widget _buildEventsListWithFilters() {
return Column(
children: [
// Barre de recherche et filtres
Container(
padding: const EdgeInsets.all(AppTheme.spacingMedium),
color: Colors.white,
child: Column(
children: [
EvenementSearchBar(
onSearch: _onSearch,
initialValue: _searchTerm,
),
const SizedBox(height: AppTheme.spacingSmall),
EvenementFilterChips(
selectedType: _selectedType,
onTypeSelected: _onFilterByType,
),
],
),
),
// Liste des événements
Expanded(
child: _buildEventsList(),
),
],
);
}
Widget _buildEventCard(EvenementModel evenement) {
return UnifiedCard.listItem(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EvenementDetailPage(evenement: evenement),
),
);
},
child: _buildEventCardContent(evenement),
);
}
Widget _buildEventCardContent(EvenementModel evenement) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.accentColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.event,
color: AppTheme.accentColor,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
evenement.titre,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
evenement.description ?? '',
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.schedule,
size: 16,
color: AppTheme.textSecondary,
),
const SizedBox(width: 4),
Text(
'${evenement.dateDebut.day}/${evenement.dateDebut.month}/${evenement.dateDebut.year}',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getTypeColor(evenement.typeEvenement).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
evenement.typeEvenement.name,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: _getTypeColor(evenement.typeEvenement),
),
),
),
],
),
],
);
}
Color _getTypeColor(TypeEvenement type) {
switch (type) {
case TypeEvenement.conference:
return AppTheme.successColor;
case TypeEvenement.assembleeGenerale:
return AppTheme.primaryColor;
case TypeEvenement.formation:
return AppTheme.warningColor;
case TypeEvenement.reunion:
return AppTheme.infoColor;
default:
return AppTheme.textSecondary;
}
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.event_busy,
size: 64,
color: AppTheme.textSecondary.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'Aucun événement',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary.withOpacity(0.7),
),
),
const SizedBox(height: 8),
Text(
'Créez votre premier événement',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary.withOpacity(0.5),
),
),
const SizedBox(height: 24),
UnifiedButton.primary(
text: 'Créer un événement',
icon: Icons.add,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const EvenementCreatePage(),
),
);
},
),
],
),
),
);
}
}

View File

@@ -132,8 +132,15 @@ class _MembreEditPageState extends State<MembreEditPage>
Widget build(BuildContext context) {
return BlocProvider.value(
value: _membresBloc,
child: WillPopScope(
onWillPop: _onWillPop,
child: PopScope(
canPop: !_hasChanges,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _onWillPop();
if (shouldPop && context.mounted) {
Navigator.of(context).pop();
}
},
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: _buildAppBar(),

View File

@@ -14,6 +14,14 @@ import '../widgets/dashboard/members_recent_activities_widget.dart';
import '../widgets/dashboard/members_advanced_filters_widget.dart';
import '../widgets/dashboard/members_smart_search_widget.dart';
import '../widgets/dashboard/members_notifications_widget.dart';
import 'membre_edit_page.dart';
// Import de l'architecture unifiée pour amélioration progressive
import '../../../../shared/widgets/common/unified_page_layout.dart';
// Imports des optimisations de performance
import '../../../../core/performance/performance_optimizer.dart';
import '../../../../shared/widgets/performance/optimized_list_view.dart';
class MembresDashboardPage extends StatefulWidget {
const MembresDashboardPage({super.key});
@@ -73,87 +81,32 @@ class _MembresDashboardPageState extends State<MembresDashboardPage> {
Widget build(BuildContext context) {
return BlocProvider.value(
value: _membresBloc,
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: AppBar(
title: const Text(
'Dashboard Membres',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 20,
),
),
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
child: BlocBuilder<MembresBloc, MembresState>(
builder: (context, state) {
// Utilisation de UnifiedPageLayout pour améliorer la cohérence
// tout en conservant TOUS les widgets spécialisés existants
return UnifiedPageLayout(
title: 'Dashboard Membres',
icon: Icons.people,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadData,
tooltip: 'Actualiser',
),
],
isLoading: state is MembresLoading,
errorMessage: state is MembresError ? state.message : null,
onRefresh: _loadData,
floatingActionButton: FloatingActionButton(
onPressed: _loadData,
tooltip: 'Actualiser',
backgroundColor: AppTheme.primaryColor,
tooltip: 'Actualiser les données',
child: const Icon(Icons.refresh, color: Colors.white),
),
],
),
body: BlocBuilder<MembresBloc, MembresState>(
builder: (context, state) {
if (state is MembresLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state is MembresError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppTheme.errorColor,
),
const SizedBox(height: 16),
const Text(
'Erreur de chargement',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(
state.message,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _loadData,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
],
),
);
}
return _buildDashboard();
},
),
floatingActionButton: FloatingActionButton(
onPressed: _loadData,
backgroundColor: AppTheme.primaryColor,
tooltip: 'Actualiser les données',
child: const Icon(Icons.refresh, color: Colors.white),
),
body: _buildDashboard(),
);
},
),
);
}
@@ -234,14 +187,17 @@ class _MembresDashboardPageState extends State<MembresDashboardPage> {
),
);
},
onMemberEdit: (member) {
// TODO: Modifier le membre
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Modification de ${member.nomComplet}'),
backgroundColor: AppTheme.warningColor,
onMemberEdit: (member) async {
final result = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MembreEditPage(membre: member),
),
);
if (result == true) {
// Recharger les données si le membre a été modifié
_membresBloc.add(const LoadMembres());
}
},
searchQuery: _currentSearchQuery,
filters: _currentFilters,

View File

@@ -0,0 +1,488 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/widgets/unified_components.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../core/models/membre_model.dart';
import '../bloc/membres_bloc.dart';
import '../bloc/membres_event.dart';
import '../bloc/membres_state.dart';
/// Dashboard des membres UnionFlow - Version Unifiée
///
/// Utilise l'architecture unifiée pour une expérience cohérente :
/// - Composants standardisés réutilisables
/// - Interface homogène avec les autres onglets
/// - Performance optimisée avec animations fluides
/// - Maintenabilité maximale
class MembresDashboardPageUnified extends StatefulWidget {
const MembresDashboardPageUnified({super.key});
@override
State<MembresDashboardPageUnified> createState() => _MembresDashboardPageUnifiedState();
}
class _MembresDashboardPageUnifiedState extends State<MembresDashboardPageUnified> {
late MembresBloc _membresBloc;
Map<String, dynamic> _currentFilters = {};
String _currentSearchQuery = '';
@override
void initState() {
super.initState();
_membresBloc = getIt<MembresBloc>();
_loadData();
}
void _loadData() {
_membresBloc.add(const LoadMembres());
}
void _onFiltersChanged(Map<String, dynamic> filters) {
setState(() {
_currentFilters = filters;
});
_loadData();
}
void _onSearchChanged(String query) {
setState(() {
_currentSearchQuery = query;
});
if (query.isNotEmpty) {
_membresBloc.add(SearchMembres(query));
} else {
_loadData();
}
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _membresBloc,
child: BlocBuilder<MembresBloc, MembresState>(
builder: (context, state) {
return UnifiedPageLayout(
title: 'Membres',
subtitle: 'Gestion des membres de l\'association',
icon: Icons.people,
iconColor: AppTheme.primaryColor,
isLoading: state is MembresLoading,
errorMessage: state is MembresError ? state.message : null,
onRefresh: _loadData,
actions: _buildActions(),
body: Column(
children: [
_buildSearchSection(),
const SizedBox(height: AppTheme.spacingLarge),
_buildKPISection(state),
const SizedBox(height: AppTheme.spacingLarge),
_buildQuickActionsSection(),
const SizedBox(height: AppTheme.spacingLarge),
_buildFiltersSection(),
const SizedBox(height: AppTheme.spacingLarge),
Expanded(child: _buildMembersList(state)),
],
),
);
},
),
);
}
/// Actions de la barre d'outils
List<Widget> _buildActions() {
return [
IconButton(
icon: const Icon(Icons.person_add),
onPressed: () {
// TODO: Navigation vers ajout membre
},
tooltip: 'Ajouter un membre',
),
IconButton(
icon: const Icon(Icons.import_export),
onPressed: () {
// TODO: Import/Export des membres
},
tooltip: 'Import/Export',
),
IconButton(
icon: const Icon(Icons.analytics),
onPressed: () {
// TODO: Navigation vers analyses détaillées
},
tooltip: 'Analyses',
),
];
}
/// Section de recherche intelligente
Widget _buildSearchSection() {
return UnifiedCard.outlined(
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingMedium),
child: Column(
children: [
TextField(
decoration: InputDecoration(
hintText: 'Rechercher un membre...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
borderSide: BorderSide.none,
),
filled: true,
fillColor: AppTheme.backgroundLight,
),
onChanged: _onSearchChanged,
),
if (_currentSearchQuery.isNotEmpty) ...[
const SizedBox(height: AppTheme.spacingSmall),
Text(
'Recherche: "$_currentSearchQuery"',
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textSecondary,
),
),
],
],
),
),
);
}
/// Section des KPI des membres
Widget _buildKPISection(MembresState state) {
final membres = state is MembresLoaded ? state.membres : <MembreModel>[];
final totalMembres = membres.length;
final membresActifs = membres.where((m) => m.statut == StatutMembre.actif).length;
final nouveauxMembres = membres.where((m) {
final now = DateTime.now();
final monthAgo = DateTime(now.year, now.month - 1, now.day);
return m.dateInscription.isAfter(monthAgo);
}).length;
final cotisationsAJour = membres.where((m) => m.cotisationAJour).length;
final kpis = [
UnifiedKPIData(
title: 'Total',
value: totalMembres.toString(),
icon: Icons.people,
color: AppTheme.primaryColor,
trend: UnifiedKPITrend(
direction: nouveauxMembres > 0 ? UnifiedKPITrendDirection.up : UnifiedKPITrendDirection.stable,
value: '+$nouveauxMembres',
label: 'ce mois',
),
),
UnifiedKPIData(
title: 'Actifs',
value: membresActifs.toString(),
icon: Icons.verified_user,
color: AppTheme.successColor,
trend: UnifiedKPITrend(
direction: UnifiedKPITrendDirection.stable,
value: '${((membresActifs / totalMembres) * 100).toInt()}%',
label: 'du total',
),
),
UnifiedKPIData(
title: 'Nouveaux',
value: nouveauxMembres.toString(),
icon: Icons.person_add,
color: AppTheme.accentColor,
trend: UnifiedKPITrend(
direction: UnifiedKPITrendDirection.up,
value: 'Ce mois',
label: 'inscriptions',
),
),
UnifiedKPIData(
title: 'À jour',
value: '${((cotisationsAJour / totalMembres) * 100).toInt()}%',
icon: Icons.account_balance_wallet,
color: AppTheme.warningColor,
trend: UnifiedKPITrend(
direction: UnifiedKPITrendDirection.stable,
value: '$cotisationsAJour/$totalMembres',
label: 'cotisations',
),
),
];
return UnifiedKPISection(
title: 'Statistiques des membres',
kpis: kpis,
);
}
/// Section des actions rapides
Widget _buildQuickActionsSection() {
final actions = [
UnifiedQuickAction(
id: 'add_member',
title: 'Nouveau\nMembre',
icon: Icons.person_add,
color: AppTheme.primaryColor,
),
UnifiedQuickAction(
id: 'bulk_import',
title: 'Import\nGroupé',
icon: Icons.upload_file,
color: AppTheme.accentColor,
),
UnifiedQuickAction(
id: 'send_message',
title: 'Message\nGroupé',
icon: Icons.send,
color: AppTheme.infoColor,
),
UnifiedQuickAction(
id: 'export_data',
title: 'Exporter\nDonnées',
icon: Icons.download,
color: AppTheme.successColor,
),
UnifiedQuickAction(
id: 'cotisations_reminder',
title: 'Rappel\nCotisations',
icon: Icons.notification_important,
color: AppTheme.warningColor,
badgeCount: 12,
),
UnifiedQuickAction(
id: 'member_reports',
title: 'Rapports\nMembres',
icon: Icons.analytics,
color: AppTheme.textSecondary,
),
];
return UnifiedQuickActionsSection(
title: 'Actions rapides',
actions: actions,
onActionTap: _handleQuickAction,
);
}
/// Section des filtres
Widget _buildFiltersSection() {
return UnifiedCard.outlined(
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.filter_list,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: AppTheme.spacingSmall),
Text(
'Filtres rapides',
style: AppTheme.titleSmall.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: AppTheme.spacingMedium),
Wrap(
spacing: AppTheme.spacingSmall,
runSpacing: AppTheme.spacingSmall,
children: [
_buildFilterChip('Tous', _currentFilters.isEmpty),
_buildFilterChip('Actifs', _currentFilters['statut'] == 'actif'),
_buildFilterChip('Inactifs', _currentFilters['statut'] == 'inactif'),
_buildFilterChip('Nouveaux', _currentFilters['type'] == 'nouveaux'),
_buildFilterChip('Cotisations en retard', _currentFilters['cotisation'] == 'retard'),
],
),
],
),
),
);
}
/// Construit un chip de filtre
Widget _buildFilterChip(String label, bool isSelected) {
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
Map<String, dynamic> newFilters = {};
if (selected) {
switch (label) {
case 'Actifs':
newFilters['statut'] = 'actif';
break;
case 'Inactifs':
newFilters['statut'] = 'inactif';
break;
case 'Nouveaux':
newFilters['type'] = 'nouveaux';
break;
case 'Cotisations en retard':
newFilters['cotisation'] = 'retard';
break;
}
}
_onFiltersChanged(newFilters);
},
selectedColor: AppTheme.primaryColor.withOpacity(0.2),
checkmarkColor: AppTheme.primaryColor,
);
}
/// Liste des membres avec composant unifié
Widget _buildMembersList(MembresState state) {
if (state is MembresLoaded) {
return UnifiedListWidget<MembreModel>(
items: state.membres,
itemBuilder: (context, membre, index) => _buildMemberCard(membre),
isLoading: false,
hasReachedMax: true,
enableAnimations: true,
emptyMessage: 'Aucun membre trouvé',
emptyIcon: Icons.people_outline,
);
}
return const Center(
child: Text('Chargement des membres...'),
);
}
/// Construit une carte de membre
Widget _buildMemberCard(MembreModel membre) {
return UnifiedCard.listItem(
onTap: () {
// TODO: Navigation vers détails du membre
},
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingMedium),
child: Row(
children: [
CircleAvatar(
backgroundColor: AppTheme.primaryColor.withOpacity(0.1),
child: Text(
membre.prenom.isNotEmpty ? membre.prenom[0].toUpperCase() : 'M',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: AppTheme.spacingMedium),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${membre.prenom} ${membre.nom}',
style: AppTheme.bodyLarge.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppTheme.spacingXSmall),
Text(
membre.email,
style: AppTheme.bodySmall.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingSmall,
vertical: AppTheme.spacingXSmall,
),
decoration: BoxDecoration(
color: _getStatusColor(membre.statut).withOpacity(0.1),
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
),
child: Text(
_getStatusLabel(membre.statut),
style: AppTheme.bodySmall.copyWith(
color: _getStatusColor(membre.statut),
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: AppTheme.spacingXSmall),
Icon(
membre.cotisationAJour ? Icons.check_circle : Icons.warning,
color: membre.cotisationAJour ? AppTheme.successColor : AppTheme.warningColor,
size: 16,
),
],
),
],
),
),
);
}
/// Obtient la couleur du statut
Color _getStatusColor(StatutMembre statut) {
switch (statut) {
case StatutMembre.actif:
return AppTheme.successColor;
case StatutMembre.inactif:
return AppTheme.errorColor;
case StatutMembre.suspendu:
return AppTheme.warningColor;
}
}
/// Obtient le libellé du statut
String _getStatusLabel(StatutMembre statut) {
switch (statut) {
case StatutMembre.actif:
return 'Actif';
case StatutMembre.inactif:
return 'Inactif';
case StatutMembre.suspendu:
return 'Suspendu';
}
}
/// Gère les actions rapides
void _handleQuickAction(UnifiedQuickAction action) {
switch (action.id) {
case 'add_member':
// TODO: Navigation vers ajout membre
break;
case 'bulk_import':
// TODO: Import groupé
break;
case 'send_message':
// TODO: Message groupé
break;
case 'export_data':
// TODO: Export des données
break;
case 'cotisations_reminder':
// TODO: Rappel cotisations
break;
case 'member_reports':
// TODO: Rapports membres
break;
}
}
@override
void dispose() {
_membresBloc.close();
super.dispose();
}
}

View File

@@ -7,7 +7,6 @@ import '../../../../core/auth/services/permission_service.dart';
import '../../../../core/services/communication_service.dart';
import '../../../../core/services/export_import_service.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/coming_soon_page.dart';
import '../../../../shared/widgets/permission_widget.dart';
import '../bloc/membres_bloc.dart';
import '../bloc/membres_event.dart';
@@ -22,6 +21,7 @@ import '../widgets/membres_view_controls.dart';
import '../widgets/membre_enhanced_card.dart';
import 'membre_details_page.dart';
import 'membre_create_page.dart';
import 'membre_edit_page.dart';
import '../widgets/error_demo_widget.dart';
@@ -540,7 +540,7 @@ class _MembresListPageState extends State<MembresListPage> with PermissionMixin
}
/// Affiche le dialog d'édition de membre
void _showEditMemberDialog(membre) {
void _showEditMemberDialog(membre) async {
// Vérifier les permissions avant d'ouvrir le formulaire
if (!permissionService.canEditMembers) {
showPermissionError(context, 'Vous n\'avez pas les permissions pour modifier les membres');
@@ -549,16 +549,16 @@ class _MembresListPageState extends State<MembresListPage> with PermissionMixin
permissionService.logAction('Ouverture formulaire édition membre', details: {'membreId': membre.id});
// TODO: Implémenter le formulaire d'édition
showDialog(
context: context,
builder: (context) => const ComingSoonPage(
title: 'Modifier le membre',
description: 'Le formulaire de modification sera bientôt disponible.',
icon: Icons.edit,
color: AppTheme.warningColor,
final result = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MembreEditPage(membre: membre),
),
);
// Si le membre a été modifié avec succès, recharger la liste
if (result == true) {
_membresBloc.add(const RefreshMembres());
}
}
/// Affiche la confirmation de suppression

View File

@@ -0,0 +1,418 @@
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/notification.dart';
part 'notification_model.g.dart';
/// Modèle de données pour les actions de notification
@JsonSerializable()
class ActionNotificationModel extends ActionNotification {
const ActionNotificationModel({
required super.id,
required super.libelle,
required super.typeAction,
super.description,
super.icone,
super.couleur,
super.url,
super.route,
super.parametres,
super.fermeNotification = true,
super.necessiteConfirmation = false,
super.estDestructive = false,
super.ordre = 0,
super.estActivee = true,
});
factory ActionNotificationModel.fromJson(Map<String, dynamic> json) =>
_$ActionNotificationModelFromJson(json);
@override
Map<String, dynamic> toJson() => _$ActionNotificationModelToJson(this);
factory ActionNotificationModel.fromEntity(ActionNotification entity) {
return ActionNotificationModel(
id: entity.id,
libelle: entity.libelle,
typeAction: entity.typeAction,
description: entity.description,
icone: entity.icone,
couleur: entity.couleur,
url: entity.url,
route: entity.route,
parametres: entity.parametres,
fermeNotification: entity.fermeNotification,
necessiteConfirmation: entity.necessiteConfirmation,
estDestructive: entity.estDestructive,
ordre: entity.ordre,
estActivee: entity.estActivee,
);
}
ActionNotification toEntity() {
return ActionNotification(
id: id,
libelle: libelle,
typeAction: typeAction,
description: description,
icone: icone,
couleur: couleur,
url: url,
route: route,
parametres: parametres,
fermeNotification: fermeNotification,
necessiteConfirmation: necessiteConfirmation,
estDestructive: estDestructive,
ordre: ordre,
estActivee: estActivee,
);
}
}
/// Modèle de données pour les notifications
@JsonSerializable()
class NotificationModel extends NotificationEntity {
const NotificationModel({
required super.id,
required super.typeNotification,
required super.statut,
required super.titre,
required super.message,
super.messageCourt,
super.expediteurId,
super.expediteurNom,
required super.destinatairesIds,
super.organisationId,
super.donneesPersonnalisees,
super.imageUrl,
super.iconeUrl,
super.actionClic,
super.parametresAction,
super.actionsRapides,
required super.dateCreation,
super.dateEnvoiProgramme,
super.dateEnvoi,
super.dateExpiration,
super.dateDerniereLecture,
super.priorite = 3,
super.estLue = false,
super.estImportante = false,
super.estArchivee = false,
super.nombreAffichages = 0,
super.nombreClics = 0,
super.tags,
super.campagneId,
super.plateforme,
super.tokenFCM,
});
factory NotificationModel.fromJson(Map<String, dynamic> json) =>
_$NotificationModelFromJson(json);
@override
Map<String, dynamic> toJson() => _$NotificationModelToJson(this);
factory NotificationModel.fromEntity(NotificationEntity entity) {
return NotificationModel(
id: entity.id,
typeNotification: entity.typeNotification,
statut: entity.statut,
titre: entity.titre,
message: entity.message,
messageCourt: entity.messageCourt,
expediteurId: entity.expediteurId,
expediteurNom: entity.expediteurNom,
destinatairesIds: entity.destinatairesIds,
organisationId: entity.organisationId,
donneesPersonnalisees: entity.donneesPersonnalisees,
imageUrl: entity.imageUrl,
iconeUrl: entity.iconeUrl,
actionClic: entity.actionClic,
parametresAction: entity.parametresAction,
actionsRapides: entity.actionsRapides?.map((action) =>
ActionNotificationModel.fromEntity(action)).toList(),
dateCreation: entity.dateCreation,
dateEnvoiProgramme: entity.dateEnvoiProgramme,
dateEnvoi: entity.dateEnvoi,
dateExpiration: entity.dateExpiration,
dateDerniereLecture: entity.dateDerniereLecture,
priorite: entity.priorite,
estLue: entity.estLue,
estImportante: entity.estImportante,
estArchivee: entity.estArchivee,
nombreAffichages: entity.nombreAffichages,
nombreClics: entity.nombreClics,
tags: entity.tags,
campagneId: entity.campagneId,
plateforme: entity.plateforme,
tokenFCM: entity.tokenFCM,
);
}
NotificationEntity toEntity() {
return NotificationEntity(
id: id,
typeNotification: typeNotification,
statut: statut,
titre: titre,
message: message,
messageCourt: messageCourt,
expediteurId: expediteurId,
expediteurNom: expediteurNom,
destinatairesIds: destinatairesIds,
organisationId: organisationId,
donneesPersonnalisees: donneesPersonnalisees,
imageUrl: imageUrl,
iconeUrl: iconeUrl,
actionClic: actionClic,
parametresAction: parametresAction,
actionsRapides: actionsRapides?.map((action) =>
(action as ActionNotificationModel).toEntity()).toList(),
dateCreation: dateCreation,
dateEnvoiProgramme: dateEnvoiProgramme,
dateEnvoi: dateEnvoi,
dateExpiration: dateExpiration,
dateDerniereLecture: dateDerniereLecture,
priorite: priorite,
estLue: estLue,
estImportante: estImportante,
estArchivee: estArchivee,
nombreAffichages: nombreAffichages,
nombreClics: nombreClics,
tags: tags,
campagneId: campagneId,
plateforme: plateforme,
tokenFCM: tokenFCM,
);
}
/// Crée un modèle depuis une notification Firebase
factory NotificationModel.fromFirebaseMessage(Map<String, dynamic> data) {
// Extraction des données de base
final id = data['id'] ?? data['notification_id'] ?? '';
final titre = data['title'] ?? data['titre'] ?? '';
final message = data['body'] ?? data['message'] ?? '';
final messageCourt = data['short_message'] ?? data['message_court'];
// Parsing du type de notification
TypeNotification typeNotification = TypeNotification.annonceGenerale;
if (data['type'] != null) {
try {
typeNotification = TypeNotification.values.firstWhere(
(type) => type.name == data['type'] || type.toString().split('.').last == data['type'],
orElse: () => TypeNotification.annonceGenerale,
);
} catch (e) {
// Utilise le type par défaut en cas d'erreur
}
}
// Parsing du statut
StatutNotification statut = StatutNotification.recue;
if (data['status'] != null) {
try {
statut = StatutNotification.values.firstWhere(
(s) => s.name == data['status'] || s.toString().split('.').last == data['status'],
orElse: () => StatutNotification.recue,
);
} catch (e) {
// Utilise le statut par défaut
}
}
// Parsing des actions rapides
List<ActionNotification>? actionsRapides;
if (data['actions'] != null && data['actions'] is List) {
try {
actionsRapides = (data['actions'] as List)
.map((actionData) => ActionNotificationModel.fromJson(
actionData is Map<String, dynamic> ? actionData : {}))
.toList();
} catch (e) {
// Ignore les erreurs de parsing des actions
}
}
// Parsing des destinataires
List<String> destinatairesIds = [];
if (data['recipients'] != null) {
if (data['recipients'] is List) {
destinatairesIds = List<String>.from(data['recipients']);
} else if (data['recipients'] is String) {
destinatairesIds = [data['recipients']];
}
}
// Parsing des tags
List<String>? tags;
if (data['tags'] != null && data['tags'] is List) {
tags = List<String>.from(data['tags']);
}
// Parsing des dates
DateTime dateCreation = DateTime.now();
if (data['created_at'] != null) {
try {
if (data['created_at'] is int) {
dateCreation = DateTime.fromMillisecondsSinceEpoch(data['created_at']);
} else if (data['created_at'] is String) {
dateCreation = DateTime.parse(data['created_at']);
}
} catch (e) {
// Utilise la date actuelle en cas d'erreur
}
}
DateTime? dateExpiration;
if (data['expires_at'] != null) {
try {
if (data['expires_at'] is int) {
dateExpiration = DateTime.fromMillisecondsSinceEpoch(data['expires_at']);
} else if (data['expires_at'] is String) {
dateExpiration = DateTime.parse(data['expires_at']);
}
} catch (e) {
// Ignore les erreurs de parsing de date
}
}
// Parsing des données personnalisées
Map<String, dynamic>? donneesPersonnalisees;
if (data['custom_data'] != null && data['custom_data'] is Map) {
donneesPersonnalisees = Map<String, dynamic>.from(data['custom_data']);
}
return NotificationModel(
id: id,
typeNotification: typeNotification,
statut: statut,
titre: titre,
message: message,
messageCourt: messageCourt,
expediteurId: data['sender_id'],
expediteurNom: data['sender_name'],
destinatairesIds: destinatairesIds,
organisationId: data['organization_id'],
donneesPersonnalisees: donneesPersonnalisees,
imageUrl: data['image_url'],
iconeUrl: data['icon_url'],
actionClic: data['click_action'],
parametresAction: data['action_params'] != null
? Map<String, String>.from(data['action_params'])
: null,
actionsRapides: actionsRapides,
dateCreation: dateCreation,
dateExpiration: dateExpiration,
priorite: data['priority'] ?? 3,
tags: tags,
campagneId: data['campaign_id'],
plateforme: data['platform'],
tokenFCM: data['fcm_token'],
);
}
/// Convertit vers le format Firebase
Map<String, dynamic> toFirebaseData() {
final data = <String, dynamic>{
'id': id,
'type': typeNotification.name,
'status': statut.name,
'title': titre,
'body': message,
'recipients': destinatairesIds,
'created_at': dateCreation.millisecondsSinceEpoch,
'priority': priorite,
};
if (messageCourt != null) data['short_message'] = messageCourt;
if (expediteurId != null) data['sender_id'] = expediteurId;
if (expediteurNom != null) data['sender_name'] = expediteurNom;
if (organisationId != null) data['organization_id'] = organisationId;
if (donneesPersonnalisees != null) data['custom_data'] = donneesPersonnalisees;
if (imageUrl != null) data['image_url'] = imageUrl;
if (iconeUrl != null) data['icon_url'] = iconeUrl;
if (actionClic != null) data['click_action'] = actionClic;
if (parametresAction != null) data['action_params'] = parametresAction;
if (dateExpiration != null) data['expires_at'] = dateExpiration!.millisecondsSinceEpoch;
if (tags != null) data['tags'] = tags;
if (campagneId != null) data['campaign_id'] = campagneId;
if (plateforme != null) data['platform'] = plateforme;
if (tokenFCM != null) data['fcm_token'] = tokenFCM;
if (actionsRapides != null && actionsRapides!.isNotEmpty) {
data['actions'] = actionsRapides!
.map((action) => (action as ActionNotificationModel).toJson())
.toList();
}
return data;
}
/// Crée une copie avec des modifications
NotificationModel copyWithModel({
String? id,
TypeNotification? typeNotification,
StatutNotification? statut,
String? titre,
String? message,
String? messageCourt,
String? expediteurId,
String? expediteurNom,
List<String>? destinatairesIds,
String? organisationId,
Map<String, dynamic>? donneesPersonnalisees,
String? imageUrl,
String? iconeUrl,
String? actionClic,
Map<String, String>? parametresAction,
List<ActionNotification>? actionsRapides,
DateTime? dateCreation,
DateTime? dateEnvoiProgramme,
DateTime? dateEnvoi,
DateTime? dateExpiration,
DateTime? dateDerniereLecture,
int? priorite,
bool? estLue,
bool? estImportante,
bool? estArchivee,
int? nombreAffichages,
int? nombreClics,
List<String>? tags,
String? campagneId,
String? plateforme,
String? tokenFCM,
}) {
return NotificationModel(
id: id ?? this.id,
typeNotification: typeNotification ?? this.typeNotification,
statut: statut ?? this.statut,
titre: titre ?? this.titre,
message: message ?? this.message,
messageCourt: messageCourt ?? this.messageCourt,
expediteurId: expediteurId ?? this.expediteurId,
expediteurNom: expediteurNom ?? this.expediteurNom,
destinatairesIds: destinatairesIds ?? this.destinatairesIds,
organisationId: organisationId ?? this.organisationId,
donneesPersonnalisees: donneesPersonnalisees ?? this.donneesPersonnalisees,
imageUrl: imageUrl ?? this.imageUrl,
iconeUrl: iconeUrl ?? this.iconeUrl,
actionClic: actionClic ?? this.actionClic,
parametresAction: parametresAction ?? this.parametresAction,
actionsRapides: actionsRapides ?? this.actionsRapides,
dateCreation: dateCreation ?? this.dateCreation,
dateEnvoiProgramme: dateEnvoiProgramme ?? this.dateEnvoiProgramme,
dateEnvoi: dateEnvoi ?? this.dateEnvoi,
dateExpiration: dateExpiration ?? this.dateExpiration,
dateDerniereLecture: dateDerniereLecture ?? this.dateDerniereLecture,
priorite: priorite ?? this.priorite,
estLue: estLue ?? this.estLue,
estImportante: estImportante ?? this.estImportante,
estArchivee: estArchivee ?? this.estArchivee,
nombreAffichages: nombreAffichages ?? this.nombreAffichages,
nombreClics: nombreClics ?? this.nombreClics,
tags: tags ?? this.tags,
campagneId: campagneId ?? this.campagneId,
plateforme: plateforme ?? this.plateforme,
tokenFCM: tokenFCM ?? this.tokenFCM,
);
}
}

View File

@@ -0,0 +1,414 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'notification.g.dart';
/// Énumération des types de notification
enum TypeNotification {
// Événements
@JsonValue('NOUVEL_EVENEMENT')
nouvelEvenement('Nouvel événement', 'evenements', 'info', 'event', '#FF9800'),
@JsonValue('RAPPEL_EVENEMENT')
rappelEvenement('Rappel d\'événement', 'evenements', 'reminder', 'schedule', '#2196F3'),
@JsonValue('EVENEMENT_ANNULE')
evenementAnnule('Événement annulé', 'evenements', 'warning', 'event_busy', '#F44336'),
@JsonValue('INSCRIPTION_CONFIRMEE')
inscriptionConfirmee('Inscription confirmée', 'evenements', 'success', 'check_circle', '#4CAF50'),
// Cotisations
@JsonValue('COTISATION_DUE')
cotisationDue('Cotisation due', 'cotisations', 'reminder', 'payment', '#FF5722'),
@JsonValue('COTISATION_PAYEE')
cotisationPayee('Cotisation payée', 'cotisations', 'success', 'paid', '#4CAF50'),
@JsonValue('PAIEMENT_CONFIRME')
paiementConfirme('Paiement confirmé', 'cotisations', 'success', 'check_circle', '#4CAF50'),
@JsonValue('PAIEMENT_ECHOUE')
paiementEchoue('Paiement échoué', 'cotisations', 'error', 'error', '#F44336'),
// Solidarité
@JsonValue('NOUVELLE_DEMANDE_AIDE')
nouvelleDemandeAide('Nouvelle demande d\'aide', 'solidarite', 'info', 'help', '#E91E63'),
@JsonValue('DEMANDE_AIDE_APPROUVEE')
demandeAideApprouvee('Demande d\'aide approuvée', 'solidarite', 'success', 'thumb_up', '#4CAF50'),
@JsonValue('AIDE_DISPONIBLE')
aideDisponible('Aide disponible', 'solidarite', 'info', 'volunteer_activism', '#E91E63'),
// Membres
@JsonValue('NOUVEAU_MEMBRE')
nouveauMembre('Nouveau membre', 'membres', 'info', 'person_add', '#2196F3'),
@JsonValue('ANNIVERSAIRE_MEMBRE')
anniversaireMembre('Anniversaire de membre', 'membres', 'celebration', 'cake', '#FF9800'),
// Organisation
@JsonValue('ANNONCE_GENERALE')
annonceGenerale('Annonce générale', 'organisation', 'info', 'campaign', '#2196F3'),
@JsonValue('REUNION_PROGRAMMEE')
reunionProgrammee('Réunion programmée', 'organisation', 'info', 'groups', '#2196F3'),
// Messages
@JsonValue('MESSAGE_PRIVE')
messagePrive('Message privé', 'messages', 'info', 'mail', '#2196F3'),
@JsonValue('MENTION')
mention('Mention', 'messages', 'info', 'alternate_email', '#FF9800'),
// Système
@JsonValue('MISE_A_JOUR_APP')
miseAJourApp('Mise à jour disponible', 'systeme', 'info', 'system_update', '#2196F3'),
@JsonValue('MAINTENANCE_PROGRAMMEE')
maintenanceProgrammee('Maintenance programmée', 'systeme', 'warning', 'build', '#FF9800');
const TypeNotification(this.libelle, this.categorie, this.priorite, this.icone, this.couleur);
final String libelle;
final String categorie;
final String priorite;
final String icone;
final String couleur;
bool get isCritique => priorite == 'urgent' || priorite == 'error';
bool get isRappel => priorite == 'reminder';
bool get isPositive => priorite == 'success' || priorite == 'celebration';
int get niveauPriorite {
switch (priorite) {
case 'urgent': return 1;
case 'error': return 2;
case 'warning': return 3;
case 'important': return 4;
case 'reminder': return 5;
case 'info': return 6;
case 'success': return 7;
case 'celebration': return 8;
default: return 6;
}
}
}
/// Énumération des statuts de notification
enum StatutNotification {
@JsonValue('BROUILLON')
brouillon('Brouillon', 'draft', '#9E9E9E'),
@JsonValue('PROGRAMMEE')
programmee('Programmée', 'scheduled', '#FF9800'),
@JsonValue('ENVOYEE')
envoyee('Envoyée', 'sent', '#4CAF50'),
@JsonValue('RECUE')
recue('Reçue', 'received', '#4CAF50'),
@JsonValue('AFFICHEE')
affichee('Affichée', 'displayed', '#2196F3'),
@JsonValue('OUVERTE')
ouverte('Ouverte', 'opened', '#4CAF50'),
@JsonValue('LUE')
lue('Lue', 'read', '#4CAF50'),
@JsonValue('NON_LUE')
nonLue('Non lue', 'unread', '#FF9800'),
@JsonValue('MARQUEE_IMPORTANTE')
marqueeImportante('Marquée importante', 'starred', '#FF9800'),
@JsonValue('SUPPRIMEE')
supprimee('Supprimée', 'deleted', '#F44336'),
@JsonValue('ARCHIVEE')
archivee('Archivée', 'archived', '#9E9E9E'),
@JsonValue('ECHEC_ENVOI')
echecEnvoi('Échec d\'envoi', 'failed', '#F44336');
const StatutNotification(this.libelle, this.code, this.couleur);
final String libelle;
final String code;
final String couleur;
bool get isSucces => this == envoyee || this == recue || this == affichee || this == ouverte || this == lue;
bool get isErreur => this == echecEnvoi;
bool get isFinal => this == supprimee || this == archivee || isErreur;
}
/// Action rapide de notification
@JsonSerializable()
class ActionNotification extends Equatable {
const ActionNotification({
required this.id,
required this.libelle,
required this.typeAction,
this.description,
this.icone,
this.couleur,
this.url,
this.route,
this.parametres,
this.fermeNotification = true,
this.necessiteConfirmation = false,
this.estDestructive = false,
this.ordre = 0,
this.estActivee = true,
});
final String id;
final String libelle;
final String? description;
final String typeAction;
final String? icone;
final String? couleur;
final String? url;
final String? route;
final Map<String, String>? parametres;
final bool fermeNotification;
final bool necessiteConfirmation;
final bool estDestructive;
final int ordre;
final bool estActivee;
factory ActionNotification.fromJson(Map<String, dynamic> json) =>
_$ActionNotificationFromJson(json);
Map<String, dynamic> toJson() => _$ActionNotificationToJson(this);
@override
List<Object?> get props => [
id, libelle, description, typeAction, icone, couleur,
url, route, parametres, fermeNotification, necessiteConfirmation,
estDestructive, ordre, estActivee,
];
ActionNotification copyWith({
String? id,
String? libelle,
String? description,
String? typeAction,
String? icone,
String? couleur,
String? url,
String? route,
Map<String, String>? parametres,
bool? fermeNotification,
bool? necessiteConfirmation,
bool? estDestructive,
int? ordre,
bool? estActivee,
}) {
return ActionNotification(
id: id ?? this.id,
libelle: libelle ?? this.libelle,
description: description ?? this.description,
typeAction: typeAction ?? this.typeAction,
icone: icone ?? this.icone,
couleur: couleur ?? this.couleur,
url: url ?? this.url,
route: route ?? this.route,
parametres: parametres ?? this.parametres,
fermeNotification: fermeNotification ?? this.fermeNotification,
necessiteConfirmation: necessiteConfirmation ?? this.necessiteConfirmation,
estDestructive: estDestructive ?? this.estDestructive,
ordre: ordre ?? this.ordre,
estActivee: estActivee ?? this.estActivee,
);
}
}
/// Entité principale de notification
@JsonSerializable()
class NotificationEntity extends Equatable {
const NotificationEntity({
required this.id,
required this.typeNotification,
required this.statut,
required this.titre,
required this.message,
this.messageCourt,
this.expediteurId,
this.expediteurNom,
required this.destinatairesIds,
this.organisationId,
this.donneesPersonnalisees,
this.imageUrl,
this.iconeUrl,
this.actionClic,
this.parametresAction,
this.actionsRapides,
required this.dateCreation,
this.dateEnvoiProgramme,
this.dateEnvoi,
this.dateExpiration,
this.dateDerniereLecture,
this.priorite = 3,
this.estLue = false,
this.estImportante = false,
this.estArchivee = false,
this.nombreAffichages = 0,
this.nombreClics = 0,
this.tags,
this.campagneId,
this.plateforme,
this.tokenFCM,
});
final String id;
final TypeNotification typeNotification;
final StatutNotification statut;
final String titre;
final String message;
final String? messageCourt;
final String? expediteurId;
final String? expediteurNom;
final List<String> destinatairesIds;
final String? organisationId;
final Map<String, dynamic>? donneesPersonnalisees;
final String? imageUrl;
final String? iconeUrl;
final String? actionClic;
final Map<String, String>? parametresAction;
final List<ActionNotification>? actionsRapides;
final DateTime dateCreation;
final DateTime? dateEnvoiProgramme;
final DateTime? dateEnvoi;
final DateTime? dateExpiration;
final DateTime? dateDerniereLecture;
final int priorite;
final bool estLue;
final bool estImportante;
final bool estArchivee;
final int nombreAffichages;
final int nombreClics;
final List<String>? tags;
final String? campagneId;
final String? plateforme;
final String? tokenFCM;
factory NotificationEntity.fromJson(Map<String, dynamic> json) =>
_$NotificationEntityFromJson(json);
Map<String, dynamic> toJson() => _$NotificationEntityToJson(this);
@override
List<Object?> get props => [
id, typeNotification, statut, titre, message, messageCourt,
expediteurId, expediteurNom, destinatairesIds, organisationId,
donneesPersonnalisees, imageUrl, iconeUrl, actionClic, parametresAction,
actionsRapides, dateCreation, dateEnvoiProgramme, dateEnvoi,
dateExpiration, dateDerniereLecture, priorite, estLue, estImportante,
estArchivee, nombreAffichages, nombreClics, tags, campagneId,
plateforme, tokenFCM,
];
NotificationEntity copyWith({
String? id,
TypeNotification? typeNotification,
StatutNotification? statut,
String? titre,
String? message,
String? messageCourt,
String? expediteurId,
String? expediteurNom,
List<String>? destinatairesIds,
String? organisationId,
Map<String, dynamic>? donneesPersonnalisees,
String? imageUrl,
String? iconeUrl,
String? actionClic,
Map<String, String>? parametresAction,
List<ActionNotification>? actionsRapides,
DateTime? dateCreation,
DateTime? dateEnvoiProgramme,
DateTime? dateEnvoi,
DateTime? dateExpiration,
DateTime? dateDerniereLecture,
int? priorite,
bool? estLue,
bool? estImportante,
bool? estArchivee,
int? nombreAffichages,
int? nombreClics,
List<String>? tags,
String? campagneId,
String? plateforme,
String? tokenFCM,
}) {
return NotificationEntity(
id: id ?? this.id,
typeNotification: typeNotification ?? this.typeNotification,
statut: statut ?? this.statut,
titre: titre ?? this.titre,
message: message ?? this.message,
messageCourt: messageCourt ?? this.messageCourt,
expediteurId: expediteurId ?? this.expediteurId,
expediteurNom: expediteurNom ?? this.expediteurNom,
destinatairesIds: destinatairesIds ?? this.destinatairesIds,
organisationId: organisationId ?? this.organisationId,
donneesPersonnalisees: donneesPersonnalisees ?? this.donneesPersonnalisees,
imageUrl: imageUrl ?? this.imageUrl,
iconeUrl: iconeUrl ?? this.iconeUrl,
actionClic: actionClic ?? this.actionClic,
parametresAction: parametresAction ?? this.parametresAction,
actionsRapides: actionsRapides ?? this.actionsRapides,
dateCreation: dateCreation ?? this.dateCreation,
dateEnvoiProgramme: dateEnvoiProgramme ?? this.dateEnvoiProgramme,
dateEnvoi: dateEnvoi ?? this.dateEnvoi,
dateExpiration: dateExpiration ?? this.dateExpiration,
dateDerniereLecture: dateDerniereLecture ?? this.dateDerniereLecture,
priorite: priorite ?? this.priorite,
estLue: estLue ?? this.estLue,
estImportante: estImportante ?? this.estImportante,
estArchivee: estArchivee ?? this.estArchivee,
nombreAffichages: nombreAffichages ?? this.nombreAffichages,
nombreClics: nombreClics ?? this.nombreClics,
tags: tags ?? this.tags,
campagneId: campagneId ?? this.campagneId,
plateforme: plateforme ?? this.plateforme,
tokenFCM: tokenFCM ?? this.tokenFCM,
);
}
/// Vérifie si la notification est expirée
bool get isExpiree {
if (dateExpiration == null) return false;
return DateTime.now().isAfter(dateExpiration!);
}
/// Vérifie si la notification est récente (moins de 24h)
bool get isRecente {
final maintenant = DateTime.now();
final difference = maintenant.difference(dateCreation);
return difference.inHours < 24;
}
/// Retourne le temps écoulé depuis la création
String get tempsEcoule {
final maintenant = DateTime.now();
final difference = maintenant.difference(dateCreation);
if (difference.inMinutes < 1) {
return 'À l\'instant';
} else if (difference.inMinutes < 60) {
return 'Il y a ${difference.inMinutes}min';
} else if (difference.inHours < 24) {
return 'Il y a ${difference.inHours}h';
} else if (difference.inDays < 7) {
return 'Il y a ${difference.inDays}j';
} else {
return 'Il y a ${(difference.inDays / 7).floor()}sem';
}
}
/// Retourne le message à afficher (court ou complet)
String get messageAffichage => messageCourt ?? message;
/// Retourne la couleur du type de notification
String get couleurType => typeNotification.couleur;
/// Retourne l'icône du type de notification
String get iconeType => typeNotification.icone;
/// Vérifie si la notification a des actions rapides
bool get hasActionsRapides => actionsRapides != null && actionsRapides!.isNotEmpty;
/// Retourne les actions rapides actives
List<ActionNotification> get actionsRapidesActives {
if (actionsRapides == null) return [];
return actionsRapides!.where((action) => action.estActivee).toList();
}
/// Calcule le taux d'engagement
double get tauxEngagement {
if (nombreAffichages == 0) return 0.0;
return (nombreClics / nombreAffichages) * 100;
}
}

View File

@@ -0,0 +1,451 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'notification.dart';
part 'preferences_notification.g.dart';
/// Énumération des canaux de notification
enum CanalNotification {
@JsonValue('URGENT_CHANNEL')
urgent('urgent', 'Notifications urgentes', 5, true, true, '#F44336'),
@JsonValue('ERROR_CHANNEL')
error('error', 'Erreurs système', 4, true, true, '#F44336'),
@JsonValue('WARNING_CHANNEL')
warning('warning', 'Avertissements', 4, true, true, '#FF9800'),
@JsonValue('IMPORTANT_CHANNEL')
important('important', 'Notifications importantes', 4, true, true, '#FF5722'),
@JsonValue('REMINDER_CHANNEL')
reminder('reminder', 'Rappels', 3, true, true, '#2196F3'),
@JsonValue('SUCCESS_CHANNEL')
success('success', 'Confirmations', 2, false, false, '#4CAF50'),
@JsonValue('DEFAULT_CHANNEL')
defaultChannel('default', 'Notifications générales', 2, false, false, '#2196F3'),
@JsonValue('EVENTS_CHANNEL')
events('events', 'Événements', 3, true, false, '#2196F3'),
@JsonValue('PAYMENTS_CHANNEL')
payments('payments', 'Paiements', 4, true, true, '#4CAF50'),
@JsonValue('SOLIDARITY_CHANNEL')
solidarity('solidarity', 'Solidarité', 3, true, false, '#E91E63'),
@JsonValue('MEMBERS_CHANNEL')
members('members', 'Membres', 2, false, false, '#2196F3'),
@JsonValue('ORGANIZATION_CHANNEL')
organization('organization', 'Organisation', 3, true, false, '#2196F3'),
@JsonValue('SYSTEM_CHANNEL')
system('system', 'Système', 2, false, false, '#607D8B'),
@JsonValue('MESSAGES_CHANNEL')
messages('messages', 'Messages', 3, true, false, '#2196F3');
const CanalNotification(this.id, this.nom, this.importance, this.sonActive,
this.vibrationActive, this.couleur);
final String id;
final String nom;
final int importance;
final bool sonActive;
final bool vibrationActive;
final String couleur;
bool get isCritique => importance >= 4;
bool get isSilencieux => !sonActive && !vibrationActive;
}
/// Préférences spécifiques à un type de notification
@JsonSerializable()
class PreferenceTypeNotification extends Equatable {
const PreferenceTypeNotification({
this.active = true,
this.priorite,
this.sonPersonnalise,
this.patternVibration,
this.couleurLED,
this.dureeAffichageSecondes,
this.doitVibrer,
this.doitEmettreSon,
this.doitAllumerLED,
this.ignoreModesilencieux = false,
});
final bool active;
final int? priorite;
final String? sonPersonnalise;
final List<int>? patternVibration;
final String? couleurLED;
final int? dureeAffichageSecondes;
final bool? doitVibrer;
final bool? doitEmettreSon;
final bool? doitAllumerLED;
final bool ignoreModesilencieux;
factory PreferenceTypeNotification.fromJson(Map<String, dynamic> json) =>
_$PreferenceTypeNotificationFromJson(json);
Map<String, dynamic> toJson() => _$PreferenceTypeNotificationToJson(this);
@override
List<Object?> get props => [
active, priorite, sonPersonnalise, patternVibration, couleurLED,
dureeAffichageSecondes, doitVibrer, doitEmettreSon, doitAllumerLED,
ignoreModesilencieux,
];
PreferenceTypeNotification copyWith({
bool? active,
int? priorite,
String? sonPersonnalise,
List<int>? patternVibration,
String? couleurLED,
int? dureeAffichageSecondes,
bool? doitVibrer,
bool? doitEmettreSon,
bool? doitAllumerLED,
bool? ignoreModesilencieux,
}) {
return PreferenceTypeNotification(
active: active ?? this.active,
priorite: priorite ?? this.priorite,
sonPersonnalise: sonPersonnalise ?? this.sonPersonnalise,
patternVibration: patternVibration ?? this.patternVibration,
couleurLED: couleurLED ?? this.couleurLED,
dureeAffichageSecondes: dureeAffichageSecondes ?? this.dureeAffichageSecondes,
doitVibrer: doitVibrer ?? this.doitVibrer,
doitEmettreSon: doitEmettreSon ?? this.doitEmettreSon,
doitAllumerLED: doitAllumerLED ?? this.doitAllumerLED,
ignoreModesilencieux: ignoreModesilencieux ?? this.ignoreModesilencieux,
);
}
}
/// Préférences spécifiques à un canal de notification
@JsonSerializable()
class PreferenceCanalNotification extends Equatable {
const PreferenceCanalNotification({
this.active = true,
this.importance,
this.sonPersonnalise,
this.patternVibration,
this.couleurLED,
this.sonActive,
this.vibrationActive,
this.ledActive,
this.peutEtreDesactive = true,
});
final bool active;
final int? importance;
final String? sonPersonnalise;
final List<int>? patternVibration;
final String? couleurLED;
final bool? sonActive;
final bool? vibrationActive;
final bool? ledActive;
final bool peutEtreDesactive;
factory PreferenceCanalNotification.fromJson(Map<String, dynamic> json) =>
_$PreferenceCanalNotificationFromJson(json);
Map<String, dynamic> toJson() => _$PreferenceCanalNotificationToJson(this);
@override
List<Object?> get props => [
active, importance, sonPersonnalise, patternVibration, couleurLED,
sonActive, vibrationActive, ledActive, peutEtreDesactive,
];
PreferenceCanalNotification copyWith({
bool? active,
int? importance,
String? sonPersonnalise,
List<int>? patternVibration,
String? couleurLED,
bool? sonActive,
bool? vibrationActive,
bool? ledActive,
bool? peutEtreDesactive,
}) {
return PreferenceCanalNotification(
active: active ?? this.active,
importance: importance ?? this.importance,
sonPersonnalise: sonPersonnalise ?? this.sonPersonnalise,
patternVibration: patternVibration ?? this.patternVibration,
couleurLED: couleurLED ?? this.couleurLED,
sonActive: sonActive ?? this.sonActive,
vibrationActive: vibrationActive ?? this.vibrationActive,
ledActive: ledActive ?? this.ledActive,
peutEtreDesactive: peutEtreDesactive ?? this.peutEtreDesactive,
);
}
}
/// Entité principale des préférences de notification
@JsonSerializable()
class PreferencesNotificationEntity extends Equatable {
const PreferencesNotificationEntity({
required this.id,
required this.utilisateurId,
this.organisationId,
this.notificationsActivees = true,
this.pushActivees = true,
this.emailActivees = true,
this.smsActivees = false,
this.inAppActivees = true,
this.typesActives,
this.typesDesactivees,
this.canauxActifs,
this.canauxDesactives,
this.modeSilencieux = false,
this.heureDebutSilencieux,
this.heureFinSilencieux,
this.joursSilencieux,
this.urgentesIgnorentSilencieux = true,
this.frequenceRegroupementMinutes = 5,
this.maxNotificationsSimultanees = 10,
this.dureeAffichageSecondes = 10,
this.vibrationActivee = true,
this.sonActive = true,
this.ledActivee = true,
this.sonPersonnalise,
this.patternVibrationPersonnalise,
this.couleurLEDPersonnalisee,
this.apercuEcranVerrouillage = true,
this.affichageHistorique = true,
this.dureeConservationJours = 30,
this.marquageLectureAutomatique = false,
this.delaiMarquageLectureSecondes,
this.archivageAutomatique = true,
this.delaiArchivageHeures = 168,
this.preferencesParType,
this.preferencesParCanal,
this.motsClesFiltre,
this.expediteursBloques,
this.expediteursPrioritaires,
this.notificationsTestActivees = false,
this.niveauLog = 'INFO',
this.tokenFCM,
this.plateforme,
this.versionApp,
this.langue = 'fr',
this.fuseauHoraire,
this.metadonnees,
});
final String id;
final String utilisateurId;
final String? organisationId;
final bool notificationsActivees;
final bool pushActivees;
final bool emailActivees;
final bool smsActivees;
final bool inAppActivees;
final Set<TypeNotification>? typesActives;
final Set<TypeNotification>? typesDesactivees;
final Set<CanalNotification>? canauxActifs;
final Set<CanalNotification>? canauxDesactives;
final bool modeSilencieux;
final String? heureDebutSilencieux; // Format HH:mm
final String? heureFinSilencieux; // Format HH:mm
final Set<int>? joursSilencieux; // 1=Lundi, 7=Dimanche
final bool urgentesIgnorentSilencieux;
final int frequenceRegroupementMinutes;
final int maxNotificationsSimultanees;
final int dureeAffichageSecondes;
final bool vibrationActivee;
final bool sonActive;
final bool ledActivee;
final String? sonPersonnalise;
final List<int>? patternVibrationPersonnalise;
final String? couleurLEDPersonnalisee;
final bool apercuEcranVerrouillage;
final bool affichageHistorique;
final int dureeConservationJours;
final bool marquageLectureAutomatique;
final int? delaiMarquageLectureSecondes;
final bool archivageAutomatique;
final int delaiArchivageHeures;
final Map<TypeNotification, PreferenceTypeNotification>? preferencesParType;
final Map<CanalNotification, PreferenceCanalNotification>? preferencesParCanal;
final Set<String>? motsClesFiltre;
final Set<String>? expediteursBloques;
final Set<String>? expediteursPrioritaires;
final bool notificationsTestActivees;
final String niveauLog;
final String? tokenFCM;
final String? plateforme;
final String? versionApp;
final String langue;
final String? fuseauHoraire;
final Map<String, dynamic>? metadonnees;
factory PreferencesNotificationEntity.fromJson(Map<String, dynamic> json) =>
_$PreferencesNotificationEntityFromJson(json);
Map<String, dynamic> toJson() => _$PreferencesNotificationEntityToJson(this);
@override
List<Object?> get props => [
id, utilisateurId, organisationId, notificationsActivees, pushActivees,
emailActivees, smsActivees, inAppActivees, typesActives, typesDesactivees,
canauxActifs, canauxDesactives, modeSilencieux, heureDebutSilencieux,
heureFinSilencieux, joursSilencieux, urgentesIgnorentSilencieux,
frequenceRegroupementMinutes, maxNotificationsSimultanees,
dureeAffichageSecondes, vibrationActivee, sonActive, ledActivee,
sonPersonnalise, patternVibrationPersonnalise, couleurLEDPersonnalisee,
apercuEcranVerrouillage, affichageHistorique, dureeConservationJours,
marquageLectureAutomatique, delaiMarquageLectureSecondes,
archivageAutomatique, delaiArchivageHeures, preferencesParType,
preferencesParCanal, motsClesFiltre, expediteursBloques,
expediteursPrioritaires, notificationsTestActivees, niveauLog,
tokenFCM, plateforme, versionApp, langue, fuseauHoraire, metadonnees,
];
PreferencesNotificationEntity copyWith({
String? id,
String? utilisateurId,
String? organisationId,
bool? notificationsActivees,
bool? pushActivees,
bool? emailActivees,
bool? smsActivees,
bool? inAppActivees,
Set<TypeNotification>? typesActives,
Set<TypeNotification>? typesDesactivees,
Set<CanalNotification>? canauxActifs,
Set<CanalNotification>? canauxDesactives,
bool? modeSilencieux,
String? heureDebutSilencieux,
String? heureFinSilencieux,
Set<int>? joursSilencieux,
bool? urgentesIgnorentSilencieux,
int? frequenceRegroupementMinutes,
int? maxNotificationsSimultanees,
int? dureeAffichageSecondes,
bool? vibrationActivee,
bool? sonActive,
bool? ledActivee,
String? sonPersonnalise,
List<int>? patternVibrationPersonnalise,
String? couleurLEDPersonnalisee,
bool? apercuEcranVerrouillage,
bool? affichageHistorique,
int? dureeConservationJours,
bool? marquageLectureAutomatique,
int? delaiMarquageLectureSecondes,
bool? archivageAutomatique,
int? delaiArchivageHeures,
Map<TypeNotification, PreferenceTypeNotification>? preferencesParType,
Map<CanalNotification, PreferenceCanalNotification>? preferencesParCanal,
Set<String>? motsClesFiltre,
Set<String>? expediteursBloques,
Set<String>? expediteursPrioritaires,
bool? notificationsTestActivees,
String? niveauLog,
String? tokenFCM,
String? plateforme,
String? versionApp,
String? langue,
String? fuseauHoraire,
Map<String, dynamic>? metadonnees,
}) {
return PreferencesNotificationEntity(
id: id ?? this.id,
utilisateurId: utilisateurId ?? this.utilisateurId,
organisationId: organisationId ?? this.organisationId,
notificationsActivees: notificationsActivees ?? this.notificationsActivees,
pushActivees: pushActivees ?? this.pushActivees,
emailActivees: emailActivees ?? this.emailActivees,
smsActivees: smsActivees ?? this.smsActivees,
inAppActivees: inAppActivees ?? this.inAppActivees,
typesActives: typesActives ?? this.typesActives,
typesDesactivees: typesDesactivees ?? this.typesDesactivees,
canauxActifs: canauxActifs ?? this.canauxActifs,
canauxDesactives: canauxDesactives ?? this.canauxDesactives,
modeSilencieux: modeSilencieux ?? this.modeSilencieux,
heureDebutSilencieux: heureDebutSilencieux ?? this.heureDebutSilencieux,
heureFinSilencieux: heureFinSilencieux ?? this.heureFinSilencieux,
joursSilencieux: joursSilencieux ?? this.joursSilencieux,
urgentesIgnorentSilencieux: urgentesIgnorentSilencieux ?? this.urgentesIgnorentSilencieux,
frequenceRegroupementMinutes: frequenceRegroupementMinutes ?? this.frequenceRegroupementMinutes,
maxNotificationsSimultanees: maxNotificationsSimultanees ?? this.maxNotificationsSimultanees,
dureeAffichageSecondes: dureeAffichageSecondes ?? this.dureeAffichageSecondes,
vibrationActivee: vibrationActivee ?? this.vibrationActivee,
sonActive: sonActive ?? this.sonActive,
ledActivee: ledActivee ?? this.ledActivee,
sonPersonnalise: sonPersonnalise ?? this.sonPersonnalise,
patternVibrationPersonnalise: patternVibrationPersonnalise ?? this.patternVibrationPersonnalise,
couleurLEDPersonnalisee: couleurLEDPersonnalisee ?? this.couleurLEDPersonnalisee,
apercuEcranVerrouillage: apercuEcranVerrouillage ?? this.apercuEcranVerrouillage,
affichageHistorique: affichageHistorique ?? this.affichageHistorique,
dureeConservationJours: dureeConservationJours ?? this.dureeConservationJours,
marquageLectureAutomatique: marquageLectureAutomatique ?? this.marquageLectureAutomatique,
delaiMarquageLectureSecondes: delaiMarquageLectureSecondes ?? this.delaiMarquageLectureSecondes,
archivageAutomatique: archivageAutomatique ?? this.archivageAutomatique,
delaiArchivageHeures: delaiArchivageHeures ?? this.delaiArchivageHeures,
preferencesParType: preferencesParType ?? this.preferencesParType,
preferencesParCanal: preferencesParCanal ?? this.preferencesParCanal,
motsClesFiltre: motsClesFiltre ?? this.motsClesFiltre,
expediteursBloques: expediteursBloques ?? this.expediteursBloques,
expediteursPrioritaires: expediteursPrioritaires ?? this.expediteursPrioritaires,
notificationsTestActivees: notificationsTestActivees ?? this.notificationsTestActivees,
niveauLog: niveauLog ?? this.niveauLog,
tokenFCM: tokenFCM ?? this.tokenFCM,
plateforme: plateforme ?? this.plateforme,
versionApp: versionApp ?? this.versionApp,
langue: langue ?? this.langue,
fuseauHoraire: fuseauHoraire ?? this.fuseauHoraire,
metadonnees: metadonnees ?? this.metadonnees,
);
}
/// Vérifie si un type de notification est activé
bool isTypeActive(TypeNotification type) {
if (!notificationsActivees) return false;
if (typesDesactivees?.contains(type) == true) return false;
if (typesActives != null) return typesActives!.contains(type);
return true; // Activé par défaut
}
/// Vérifie si un canal de notification est activé
bool isCanalActif(CanalNotification canal) {
if (!notificationsActivees) return false;
if (canauxDesactives?.contains(canal) == true) return false;
if (canauxActifs != null) return canauxActifs!.contains(canal);
return true; // Activé par défaut
}
/// Vérifie si on est en mode silencieux actuellement
bool get isEnModeSilencieux {
if (!modeSilencieux) return false;
if (heureDebutSilencieux == null || heureFinSilencieux == null) return false;
final maintenant = DateTime.now();
final heureActuelle = '${maintenant.hour.toString().padLeft(2, '0')}:${maintenant.minute.toString().padLeft(2, '0')}';
// Gestion du cas où la période traverse minuit
if (heureDebutSilencieux!.compareTo(heureFinSilencieux!) > 0) {
return heureActuelle.compareTo(heureDebutSilencieux!) >= 0 ||
heureActuelle.compareTo(heureFinSilencieux!) <= 0;
} else {
return heureActuelle.compareTo(heureDebutSilencieux!) >= 0 &&
heureActuelle.compareTo(heureFinSilencieux!) <= 0;
}
}
/// Vérifie si un expéditeur est bloqué
bool isExpediteurBloque(String? expediteurId) {
if (expediteurId == null) return false;
return expediteursBloques?.contains(expediteurId) == true;
}
/// Vérifie si un expéditeur est prioritaire
bool isExpediteurPrioritaire(String? expediteurId) {
if (expediteurId == null) return false;
return expediteursPrioritaires?.contains(expediteurId) == true;
}
/// Crée des préférences par défaut pour un utilisateur
static PreferencesNotificationEntity creerDefaut(String utilisateurId) {
return PreferencesNotificationEntity(
id: 'pref_$utilisateurId',
utilisateurId: utilisateurId,
);
}
}

View File

@@ -0,0 +1,310 @@
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/notification.dart';
import '../entities/preferences_notification.dart';
/// Repository abstrait pour la gestion des notifications
abstract class NotificationsRepository {
// === GESTION DES NOTIFICATIONS ===
/// Récupère les notifications d'un utilisateur
///
/// [utilisateurId] ID de l'utilisateur
/// [includeArchivees] Inclure les notifications archivées
/// [limite] Nombre maximum de notifications à retourner
/// [offset] Décalage pour la pagination
Future<Either<Failure, List<NotificationEntity>>> obtenirNotifications({
required String utilisateurId,
bool includeArchivees = false,
int limite = 50,
int offset = 0,
});
/// Récupère une notification spécifique
///
/// [notificationId] ID de la notification
Future<Either<Failure, NotificationEntity>> obtenirNotification(String notificationId);
/// Marque une notification comme lue
///
/// [notificationId] ID de la notification
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, void>> marquerCommeLue(String notificationId, String utilisateurId);
/// Marque toutes les notifications comme lues
///
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, void>> marquerToutesCommeLues(String utilisateurId);
/// Marque une notification comme importante
///
/// [notificationId] ID de la notification
/// [utilisateurId] ID de l'utilisateur
/// [importante] true pour marquer comme importante, false pour enlever
Future<Either<Failure, void>> marquerCommeImportante(
String notificationId,
String utilisateurId,
bool importante,
);
/// Archive une notification
///
/// [notificationId] ID de la notification
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, void>> archiverNotification(String notificationId, String utilisateurId);
/// Archive toutes les notifications lues
///
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, void>> archiverToutesLues(String utilisateurId);
/// Supprime une notification
///
/// [notificationId] ID de la notification
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, void>> supprimerNotification(String notificationId, String utilisateurId);
/// Supprime toutes les notifications archivées
///
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, void>> supprimerToutesArchivees(String utilisateurId);
// === FILTRAGE ET RECHERCHE ===
/// Recherche des notifications par critères
///
/// [utilisateurId] ID de l'utilisateur
/// [query] Texte de recherche
/// [types] Types de notifications à inclure
/// [statuts] Statuts de notifications à inclure
/// [dateDebut] Date de début de la période
/// [dateFin] Date de fin de la période
/// [limite] Nombre maximum de résultats
Future<Either<Failure, List<NotificationEntity>>> rechercherNotifications({
required String utilisateurId,
String? query,
List<TypeNotification>? types,
List<StatutNotification>? statuts,
DateTime? dateDebut,
DateTime? dateFin,
int limite = 50,
});
/// Récupère les notifications par type
///
/// [utilisateurId] ID de l'utilisateur
/// [type] Type de notification
/// [limite] Nombre maximum de notifications
Future<Either<Failure, List<NotificationEntity>>> obtenirNotificationsParType(
String utilisateurId,
TypeNotification type, {
int limite = 50,
});
/// Récupère les notifications non lues
///
/// [utilisateurId] ID de l'utilisateur
/// [limite] Nombre maximum de notifications
Future<Either<Failure, List<NotificationEntity>>> obtenirNotificationsNonLues(
String utilisateurId, {
int limite = 50,
});
/// Récupère les notifications importantes
///
/// [utilisateurId] ID de l'utilisateur
/// [limite] Nombre maximum de notifications
Future<Either<Failure, List<NotificationEntity>>> obtenirNotificationsImportantes(
String utilisateurId, {
int limite = 50,
});
// === STATISTIQUES ===
/// Récupère le nombre de notifications non lues
///
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, int>> obtenirNombreNonLues(String utilisateurId);
/// Récupère les statistiques des notifications
///
/// [utilisateurId] ID de l'utilisateur
/// [periode] Période d'analyse (en jours)
Future<Either<Failure, Map<String, dynamic>>> obtenirStatistiques(
String utilisateurId, {
int periode = 30,
});
// === ACTIONS SUR LES NOTIFICATIONS ===
/// Exécute une action rapide sur une notification
///
/// [notificationId] ID de la notification
/// [actionId] ID de l'action à exécuter
/// [utilisateurId] ID de l'utilisateur
/// [parametres] Paramètres additionnels pour l'action
Future<Either<Failure, Map<String, dynamic>>> executerActionRapide(
String notificationId,
String actionId,
String utilisateurId, {
Map<String, dynamic>? parametres,
});
/// Signale une notification comme spam
///
/// [notificationId] ID de la notification
/// [utilisateurId] ID de l'utilisateur
/// [raison] Raison du signalement
Future<Either<Failure, void>> signalerSpam(
String notificationId,
String utilisateurId,
String raison,
);
// === PRÉFÉRENCES DE NOTIFICATION ===
/// Récupère les préférences de notification d'un utilisateur
///
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, PreferencesNotificationEntity>> obtenirPreferences(String utilisateurId);
/// Met à jour les préférences de notification
///
/// [preferences] Nouvelles préférences
Future<Either<Failure, void>> mettreAJourPreferences(PreferencesNotificationEntity preferences);
/// Réinitialise les préférences aux valeurs par défaut
///
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, PreferencesNotificationEntity>> reinitialiserPreferences(String utilisateurId);
/// Active/désactive un type de notification
///
/// [utilisateurId] ID de l'utilisateur
/// [type] Type de notification
/// [active] true pour activer, false pour désactiver
Future<Either<Failure, void>> toggleTypeNotification(
String utilisateurId,
TypeNotification type,
bool active,
);
/// Active/désactive un canal de notification
///
/// [utilisateurId] ID de l'utilisateur
/// [canal] Canal de notification
/// [active] true pour activer, false pour désactiver
Future<Either<Failure, void>> toggleCanalNotification(
String utilisateurId,
CanalNotification canal,
bool active,
);
/// Configure le mode silencieux
///
/// [utilisateurId] ID de l'utilisateur
/// [active] true pour activer le mode silencieux
/// [heureDebut] Heure de début (format HH:mm)
/// [heureFin] Heure de fin (format HH:mm)
/// [jours] Jours de la semaine (1=Lundi, 7=Dimanche)
Future<Either<Failure, void>> configurerModeSilencieux(
String utilisateurId,
bool active, {
String? heureDebut,
String? heureFin,
Set<int>? jours,
});
// === GESTION DES TOKENS FCM ===
/// Enregistre ou met à jour le token FCM
///
/// [utilisateurId] ID de l'utilisateur
/// [token] Token FCM
/// [plateforme] Plateforme (android, ios)
Future<Either<Failure, void>> enregistrerTokenFCM(
String utilisateurId,
String token,
String plateforme,
);
/// Supprime le token FCM
///
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, void>> supprimerTokenFCM(String utilisateurId);
// === NOTIFICATIONS DE TEST ===
/// Envoie une notification de test
///
/// [utilisateurId] ID de l'utilisateur
/// [type] Type de notification à tester
Future<Either<Failure, NotificationEntity>> envoyerNotificationTest(
String utilisateurId,
TypeNotification type,
);
// === CACHE ET SYNCHRONISATION ===
/// Synchronise les notifications avec le serveur
///
/// [utilisateurId] ID de l'utilisateur
/// [forceSync] Force la synchronisation même si le cache est récent
Future<Either<Failure, void>> synchroniser(String utilisateurId, {bool forceSync = false});
/// Vide le cache des notifications
///
/// [utilisateurId] ID de l'utilisateur (optionnel, vide tout si null)
Future<Either<Failure, void>> viderCache([String? utilisateurId]);
/// Vérifie si les données sont en cache et récentes
///
/// [utilisateurId] ID de l'utilisateur
/// [maxAgeMinutes] Âge maximum du cache en minutes
Future<bool> isCacheValide(String utilisateurId, {int maxAgeMinutes = 5});
// === ABONNEMENTS ET TOPICS ===
/// S'abonne à un topic de notifications
///
/// [utilisateurId] ID de l'utilisateur
/// [topic] Nom du topic
Future<Either<Failure, void>> abonnerAuTopic(String utilisateurId, String topic);
/// Se désabonne d'un topic de notifications
///
/// [utilisateurId] ID de l'utilisateur
/// [topic] Nom du topic
Future<Either<Failure, void>> desabonnerDuTopic(String utilisateurId, String topic);
/// Récupère la liste des topics auxquels l'utilisateur est abonné
///
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, List<String>>> obtenirTopicsAbornes(String utilisateurId);
// === EXPORT ET SAUVEGARDE ===
/// Exporte les notifications vers un fichier
///
/// [utilisateurId] ID de l'utilisateur
/// [format] Format d'export (json, csv)
/// [dateDebut] Date de début de la période
/// [dateFin] Date de fin de la période
Future<Either<Failure, String>> exporterNotifications(
String utilisateurId,
String format, {
DateTime? dateDebut,
DateTime? dateFin,
});
/// Sauvegarde les notifications localement
///
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, void>> sauvegarderLocalement(String utilisateurId);
/// Restaure les notifications depuis une sauvegarde locale
///
/// [utilisateurId] ID de l'utilisateur
Future<Either<Failure, void>> restaurerDepuisSauvegarde(String utilisateurId);
}

View File

@@ -0,0 +1,388 @@
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/notification.dart';
import '../repositories/notifications_repository.dart';
/// Use case pour marquer une notification comme lue
class MarquerCommeLueUseCase implements UseCase<void, MarquerCommeLueParams> {
final NotificationsRepository repository;
MarquerCommeLueUseCase(this.repository);
@override
Future<Either<Failure, void>> call(MarquerCommeLueParams params) async {
return await repository.marquerCommeLue(
params.notificationId,
params.utilisateurId,
);
}
}
/// Paramètres pour marquer comme lue
class MarquerCommeLueParams {
final String notificationId;
final String utilisateurId;
const MarquerCommeLueParams({
required this.notificationId,
required this.utilisateurId,
});
@override
String toString() {
return 'MarquerCommeLueParams{notificationId: $notificationId, utilisateurId: $utilisateurId}';
}
}
/// Use case pour marquer toutes les notifications comme lues
class MarquerToutesCommeLuesUseCase implements UseCase<void, String> {
final NotificationsRepository repository;
MarquerToutesCommeLuesUseCase(this.repository);
@override
Future<Either<Failure, void>> call(String utilisateurId) async {
return await repository.marquerToutesCommeLues(utilisateurId);
}
}
/// Use case pour marquer une notification comme importante
class MarquerCommeImportanteUseCase implements UseCase<void, MarquerCommeImportanteParams> {
final NotificationsRepository repository;
MarquerCommeImportanteUseCase(this.repository);
@override
Future<Either<Failure, void>> call(MarquerCommeImportanteParams params) async {
return await repository.marquerCommeImportante(
params.notificationId,
params.utilisateurId,
params.importante,
);
}
}
/// Paramètres pour marquer comme importante
class MarquerCommeImportanteParams {
final String notificationId;
final String utilisateurId;
final bool importante;
const MarquerCommeImportanteParams({
required this.notificationId,
required this.utilisateurId,
required this.importante,
});
@override
String toString() {
return 'MarquerCommeImportanteParams{notificationId: $notificationId, utilisateurId: $utilisateurId, importante: $importante}';
}
}
/// Use case pour archiver une notification
class ArchiverNotificationUseCase implements UseCase<void, ArchiverNotificationParams> {
final NotificationsRepository repository;
ArchiverNotificationUseCase(this.repository);
@override
Future<Either<Failure, void>> call(ArchiverNotificationParams params) async {
return await repository.archiverNotification(
params.notificationId,
params.utilisateurId,
);
}
}
/// Paramètres pour archiver une notification
class ArchiverNotificationParams {
final String notificationId;
final String utilisateurId;
const ArchiverNotificationParams({
required this.notificationId,
required this.utilisateurId,
});
@override
String toString() {
return 'ArchiverNotificationParams{notificationId: $notificationId, utilisateurId: $utilisateurId}';
}
}
/// Use case pour archiver toutes les notifications lues
class ArchiverToutesLuesUseCase implements UseCase<void, String> {
final NotificationsRepository repository;
ArchiverToutesLuesUseCase(this.repository);
@override
Future<Either<Failure, void>> call(String utilisateurId) async {
return await repository.archiverToutesLues(utilisateurId);
}
}
/// Use case pour supprimer une notification
class SupprimerNotificationUseCase implements UseCase<void, SupprimerNotificationParams> {
final NotificationsRepository repository;
SupprimerNotificationUseCase(this.repository);
@override
Future<Either<Failure, void>> call(SupprimerNotificationParams params) async {
return await repository.supprimerNotification(
params.notificationId,
params.utilisateurId,
);
}
}
/// Paramètres pour supprimer une notification
class SupprimerNotificationParams {
final String notificationId;
final String utilisateurId;
const SupprimerNotificationParams({
required this.notificationId,
required this.utilisateurId,
});
@override
String toString() {
return 'SupprimerNotificationParams{notificationId: $notificationId, utilisateurId: $utilisateurId}';
}
}
/// Use case pour supprimer toutes les notifications archivées
class SupprimerToutesArchiveesUseCase implements UseCase<void, String> {
final NotificationsRepository repository;
SupprimerToutesArchiveesUseCase(this.repository);
@override
Future<Either<Failure, void>> call(String utilisateurId) async {
return await repository.supprimerToutesArchivees(utilisateurId);
}
}
/// Use case pour exécuter une action rapide
class ExecuterActionRapideUseCase implements UseCase<Map<String, dynamic>, ExecuterActionRapideParams> {
final NotificationsRepository repository;
ExecuterActionRapideUseCase(this.repository);
@override
Future<Either<Failure, Map<String, dynamic>>> call(ExecuterActionRapideParams params) async {
return await repository.executerActionRapide(
params.notificationId,
params.actionId,
params.utilisateurId,
parametres: params.parametres,
);
}
}
/// Paramètres pour exécuter une action rapide
class ExecuterActionRapideParams {
final String notificationId;
final String actionId;
final String utilisateurId;
final Map<String, dynamic>? parametres;
const ExecuterActionRapideParams({
required this.notificationId,
required this.actionId,
required this.utilisateurId,
this.parametres,
});
ExecuterActionRapideParams copyWith({
String? notificationId,
String? actionId,
String? utilisateurId,
Map<String, dynamic>? parametres,
}) {
return ExecuterActionRapideParams(
notificationId: notificationId ?? this.notificationId,
actionId: actionId ?? this.actionId,
utilisateurId: utilisateurId ?? this.utilisateurId,
parametres: parametres ?? this.parametres,
);
}
@override
String toString() {
return 'ExecuterActionRapideParams{notificationId: $notificationId, actionId: $actionId, utilisateurId: $utilisateurId, parametres: $parametres}';
}
}
/// Use case pour signaler une notification comme spam
class SignalerSpamUseCase implements UseCase<void, SignalerSpamParams> {
final NotificationsRepository repository;
SignalerSpamUseCase(this.repository);
@override
Future<Either<Failure, void>> call(SignalerSpamParams params) async {
return await repository.signalerSpam(
params.notificationId,
params.utilisateurId,
params.raison,
);
}
}
/// Paramètres pour signaler comme spam
class SignalerSpamParams {
final String notificationId;
final String utilisateurId;
final String raison;
const SignalerSpamParams({
required this.notificationId,
required this.utilisateurId,
required this.raison,
});
@override
String toString() {
return 'SignalerSpamParams{notificationId: $notificationId, utilisateurId: $utilisateurId, raison: $raison}';
}
}
/// Use case pour synchroniser les notifications
class SynchroniserNotificationsUseCase implements UseCase<void, SynchroniserNotificationsParams> {
final NotificationsRepository repository;
SynchroniserNotificationsUseCase(this.repository);
@override
Future<Either<Failure, void>> call(SynchroniserNotificationsParams params) async {
return await repository.synchroniser(
params.utilisateurId,
forceSync: params.forceSync,
);
}
}
/// Paramètres pour synchroniser les notifications
class SynchroniserNotificationsParams {
final String utilisateurId;
final bool forceSync;
const SynchroniserNotificationsParams({
required this.utilisateurId,
this.forceSync = false,
});
SynchroniserNotificationsParams copyWith({
String? utilisateurId,
bool? forceSync,
}) {
return SynchroniserNotificationsParams(
utilisateurId: utilisateurId ?? this.utilisateurId,
forceSync: forceSync ?? this.forceSync,
);
}
@override
String toString() {
return 'SynchroniserNotificationsParams{utilisateurId: $utilisateurId, forceSync: $forceSync}';
}
}
/// Use case pour vider le cache des notifications
class ViderCacheNotificationsUseCase implements UseCase<void, String?> {
final NotificationsRepository repository;
ViderCacheNotificationsUseCase(this.repository);
@override
Future<Either<Failure, void>> call(String? utilisateurId) async {
return await repository.viderCache(utilisateurId);
}
}
/// Use case pour envoyer une notification de test
class EnvoyerNotificationTestUseCase implements UseCase<NotificationEntity, EnvoyerNotificationTestParams> {
final NotificationsRepository repository;
EnvoyerNotificationTestUseCase(this.repository);
@override
Future<Either<Failure, NotificationEntity>> call(EnvoyerNotificationTestParams params) async {
return await repository.envoyerNotificationTest(
params.utilisateurId,
params.type,
);
}
}
/// Paramètres pour envoyer une notification de test
class EnvoyerNotificationTestParams {
final String utilisateurId;
final TypeNotification type;
const EnvoyerNotificationTestParams({
required this.utilisateurId,
required this.type,
});
@override
String toString() {
return 'EnvoyerNotificationTestParams{utilisateurId: $utilisateurId, type: $type}';
}
}
/// Use case pour exporter les notifications
class ExporterNotificationsUseCase implements UseCase<String, ExporterNotificationsParams> {
final NotificationsRepository repository;
ExporterNotificationsUseCase(this.repository);
@override
Future<Either<Failure, String>> call(ExporterNotificationsParams params) async {
return await repository.exporterNotifications(
params.utilisateurId,
params.format,
dateDebut: params.dateDebut,
dateFin: params.dateFin,
);
}
}
/// Paramètres pour exporter les notifications
class ExporterNotificationsParams {
final String utilisateurId;
final String format;
final DateTime? dateDebut;
final DateTime? dateFin;
const ExporterNotificationsParams({
required this.utilisateurId,
required this.format,
this.dateDebut,
this.dateFin,
});
ExporterNotificationsParams copyWith({
String? utilisateurId,
String? format,
DateTime? dateDebut,
DateTime? dateFin,
}) {
return ExporterNotificationsParams(
utilisateurId: utilisateurId ?? this.utilisateurId,
format: format ?? this.format,
dateDebut: dateDebut ?? this.dateDebut,
dateFin: dateFin ?? this.dateFin,
);
}
@override
String toString() {
return 'ExporterNotificationsParams{utilisateurId: $utilisateurId, format: $format, dateDebut: $dateDebut, dateFin: $dateFin}';
}
}

View File

@@ -0,0 +1,369 @@
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/notification.dart';
import '../entities/preferences_notification.dart';
import '../repositories/notifications_repository.dart';
/// Use case pour obtenir les préférences de notification
class ObtenirPreferencesUseCase implements UseCase<PreferencesNotificationEntity, String> {
final NotificationsRepository repository;
ObtenirPreferencesUseCase(this.repository);
@override
Future<Either<Failure, PreferencesNotificationEntity>> call(String utilisateurId) async {
return await repository.obtenirPreferences(utilisateurId);
}
}
/// Use case pour mettre à jour les préférences de notification
class MettreAJourPreferencesUseCase implements UseCase<void, PreferencesNotificationEntity> {
final NotificationsRepository repository;
MettreAJourPreferencesUseCase(this.repository);
@override
Future<Either<Failure, void>> call(PreferencesNotificationEntity preferences) async {
return await repository.mettreAJourPreferences(preferences);
}
}
/// Use case pour réinitialiser les préférences
class ReinitialiserPreferencesUseCase implements UseCase<PreferencesNotificationEntity, String> {
final NotificationsRepository repository;
ReinitialiserPreferencesUseCase(this.repository);
@override
Future<Either<Failure, PreferencesNotificationEntity>> call(String utilisateurId) async {
return await repository.reinitialiserPreferences(utilisateurId);
}
}
/// Use case pour activer/désactiver un type de notification
class ToggleTypeNotificationUseCase implements UseCase<void, ToggleTypeNotificationParams> {
final NotificationsRepository repository;
ToggleTypeNotificationUseCase(this.repository);
@override
Future<Either<Failure, void>> call(ToggleTypeNotificationParams params) async {
return await repository.toggleTypeNotification(
params.utilisateurId,
params.type,
params.active,
);
}
}
/// Paramètres pour activer/désactiver un type de notification
class ToggleTypeNotificationParams {
final String utilisateurId;
final TypeNotification type;
final bool active;
const ToggleTypeNotificationParams({
required this.utilisateurId,
required this.type,
required this.active,
});
@override
String toString() {
return 'ToggleTypeNotificationParams{utilisateurId: $utilisateurId, type: $type, active: $active}';
}
}
/// Use case pour activer/désactiver un canal de notification
class ToggleCanalNotificationUseCase implements UseCase<void, ToggleCanalNotificationParams> {
final NotificationsRepository repository;
ToggleCanalNotificationUseCase(this.repository);
@override
Future<Either<Failure, void>> call(ToggleCanalNotificationParams params) async {
return await repository.toggleCanalNotification(
params.utilisateurId,
params.canal,
params.active,
);
}
}
/// Paramètres pour activer/désactiver un canal de notification
class ToggleCanalNotificationParams {
final String utilisateurId;
final CanalNotification canal;
final bool active;
const ToggleCanalNotificationParams({
required this.utilisateurId,
required this.canal,
required this.active,
});
@override
String toString() {
return 'ToggleCanalNotificationParams{utilisateurId: $utilisateurId, canal: $canal, active: $active}';
}
}
/// Use case pour configurer le mode silencieux
class ConfigurerModeSilencieuxUseCase implements UseCase<void, ConfigurerModeSilencieuxParams> {
final NotificationsRepository repository;
ConfigurerModeSilencieuxUseCase(this.repository);
@override
Future<Either<Failure, void>> call(ConfigurerModeSilencieuxParams params) async {
return await repository.configurerModeSilencieux(
params.utilisateurId,
params.active,
heureDebut: params.heureDebut,
heureFin: params.heureFin,
jours: params.jours,
);
}
}
/// Paramètres pour configurer le mode silencieux
class ConfigurerModeSilencieuxParams {
final String utilisateurId;
final bool active;
final String? heureDebut;
final String? heureFin;
final Set<int>? jours;
const ConfigurerModeSilencieuxParams({
required this.utilisateurId,
required this.active,
this.heureDebut,
this.heureFin,
this.jours,
});
ConfigurerModeSilencieuxParams copyWith({
String? utilisateurId,
bool? active,
String? heureDebut,
String? heureFin,
Set<int>? jours,
}) {
return ConfigurerModeSilencieuxParams(
utilisateurId: utilisateurId ?? this.utilisateurId,
active: active ?? this.active,
heureDebut: heureDebut ?? this.heureDebut,
heureFin: heureFin ?? this.heureFin,
jours: jours ?? this.jours,
);
}
@override
String toString() {
return 'ConfigurerModeSilencieuxParams{utilisateurId: $utilisateurId, active: $active, heureDebut: $heureDebut, heureFin: $heureFin, jours: $jours}';
}
}
/// Use case pour enregistrer le token FCM
class EnregistrerTokenFCMUseCase implements UseCase<void, EnregistrerTokenFCMParams> {
final NotificationsRepository repository;
EnregistrerTokenFCMUseCase(this.repository);
@override
Future<Either<Failure, void>> call(EnregistrerTokenFCMParams params) async {
return await repository.enregistrerTokenFCM(
params.utilisateurId,
params.token,
params.plateforme,
);
}
}
/// Paramètres pour enregistrer le token FCM
class EnregistrerTokenFCMParams {
final String utilisateurId;
final String token;
final String plateforme;
const EnregistrerTokenFCMParams({
required this.utilisateurId,
required this.token,
required this.plateforme,
});
@override
String toString() {
return 'EnregistrerTokenFCMParams{utilisateurId: $utilisateurId, token: $token, plateforme: $plateforme}';
}
}
/// Use case pour supprimer le token FCM
class SupprimerTokenFCMUseCase implements UseCase<void, String> {
final NotificationsRepository repository;
SupprimerTokenFCMUseCase(this.repository);
@override
Future<Either<Failure, void>> call(String utilisateurId) async {
return await repository.supprimerTokenFCM(utilisateurId);
}
}
/// Use case pour s'abonner à un topic
class AbonnerAuTopicUseCase implements UseCase<void, AbonnerAuTopicParams> {
final NotificationsRepository repository;
AbonnerAuTopicUseCase(this.repository);
@override
Future<Either<Failure, void>> call(AbonnerAuTopicParams params) async {
return await repository.abonnerAuTopic(
params.utilisateurId,
params.topic,
);
}
}
/// Paramètres pour s'abonner à un topic
class AbonnerAuTopicParams {
final String utilisateurId;
final String topic;
const AbonnerAuTopicParams({
required this.utilisateurId,
required this.topic,
});
@override
String toString() {
return 'AbonnerAuTopicParams{utilisateurId: $utilisateurId, topic: $topic}';
}
}
/// Use case pour se désabonner d'un topic
class DesabonnerDuTopicUseCase implements UseCase<void, DesabonnerDuTopicParams> {
final NotificationsRepository repository;
DesabonnerDuTopicUseCase(this.repository);
@override
Future<Either<Failure, void>> call(DesabonnerDuTopicParams params) async {
return await repository.desabonnerDuTopic(
params.utilisateurId,
params.topic,
);
}
}
/// Paramètres pour se désabonner d'un topic
class DesabonnerDuTopicParams {
final String utilisateurId;
final String topic;
const DesabonnerDuTopicParams({
required this.utilisateurId,
required this.topic,
});
@override
String toString() {
return 'DesabonnerDuTopicParams{utilisateurId: $utilisateurId, topic: $topic}';
}
}
/// Use case pour obtenir les topics auxquels l'utilisateur est abonné
class ObtenirTopicsAbornesUseCase implements UseCase<List<String>, String> {
final NotificationsRepository repository;
ObtenirTopicsAbornesUseCase(this.repository);
@override
Future<Either<Failure, List<String>>> call(String utilisateurId) async {
return await repository.obtenirTopicsAbornes(utilisateurId);
}
}
/// Use case pour configurer les préférences avancées
class ConfigurerPreferencesAvanceesUseCase implements UseCase<void, ConfigurerPreferencesAvanceesParams> {
final NotificationsRepository repository;
ConfigurerPreferencesAvanceesUseCase(this.repository);
@override
Future<Either<Failure, void>> call(ConfigurerPreferencesAvanceesParams params) async {
// Récupération des préférences actuelles
final preferencesResult = await repository.obtenirPreferences(params.utilisateurId);
return preferencesResult.fold(
(failure) => Left(failure),
(preferences) async {
// Mise à jour des préférences avec les nouveaux paramètres
final preferencesModifiees = preferences.copyWith(
vibrationActivee: params.vibrationActivee ?? preferences.vibrationActivee,
sonActive: params.sonActive ?? preferences.sonActive,
ledActivee: params.ledActivee ?? preferences.ledActivee,
sonPersonnalise: params.sonPersonnalise ?? preferences.sonPersonnalise,
patternVibrationPersonnalise: params.patternVibrationPersonnalise ?? preferences.patternVibrationPersonnalise,
couleurLEDPersonnalisee: params.couleurLEDPersonnalisee ?? preferences.couleurLEDPersonnalisee,
apercuEcranVerrouillage: params.apercuEcranVerrouillage ?? preferences.apercuEcranVerrouillage,
dureeAffichageSecondes: params.dureeAffichageSecondes ?? preferences.dureeAffichageSecondes,
frequenceRegroupementMinutes: params.frequenceRegroupementMinutes ?? preferences.frequenceRegroupementMinutes,
maxNotificationsSimultanees: params.maxNotificationsSimultanees ?? preferences.maxNotificationsSimultanees,
marquageLectureAutomatique: params.marquageLectureAutomatique ?? preferences.marquageLectureAutomatique,
delaiMarquageLectureSecondes: params.delaiMarquageLectureSecondes ?? preferences.delaiMarquageLectureSecondes,
archivageAutomatique: params.archivageAutomatique ?? preferences.archivageAutomatique,
delaiArchivageHeures: params.delaiArchivageHeures ?? preferences.delaiArchivageHeures,
dureeConservationJours: params.dureeConservationJours ?? preferences.dureeConservationJours,
);
return await repository.mettreAJourPreferences(preferencesModifiees);
},
);
}
}
/// Paramètres pour configurer les préférences avancées
class ConfigurerPreferencesAvanceesParams {
final String utilisateurId;
final bool? vibrationActivee;
final bool? sonActive;
final bool? ledActivee;
final String? sonPersonnalise;
final List<int>? patternVibrationPersonnalise;
final String? couleurLEDPersonnalisee;
final bool? apercuEcranVerrouillage;
final int? dureeAffichageSecondes;
final int? frequenceRegroupementMinutes;
final int? maxNotificationsSimultanees;
final bool? marquageLectureAutomatique;
final int? delaiMarquageLectureSecondes;
final bool? archivageAutomatique;
final int? delaiArchivageHeures;
final int? dureeConservationJours;
const ConfigurerPreferencesAvanceesParams({
required this.utilisateurId,
this.vibrationActivee,
this.sonActive,
this.ledActivee,
this.sonPersonnalise,
this.patternVibrationPersonnalise,
this.couleurLEDPersonnalisee,
this.apercuEcranVerrouillage,
this.dureeAffichageSecondes,
this.frequenceRegroupementMinutes,
this.maxNotificationsSimultanees,
this.marquageLectureAutomatique,
this.delaiMarquageLectureSecondes,
this.archivageAutomatique,
this.delaiArchivageHeures,
this.dureeConservationJours,
});
@override
String toString() {
return 'ConfigurerPreferencesAvanceesParams{utilisateurId: $utilisateurId, vibrationActivee: $vibrationActivee, sonActive: $sonActive, ledActivee: $ledActivee, ...}';
}
}

View File

@@ -0,0 +1,274 @@
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/notification.dart';
import '../repositories/notifications_repository.dart';
/// Use case pour obtenir les notifications d'un utilisateur
class ObtenirNotificationsUseCase implements UseCase<List<NotificationEntity>, ObtenirNotificationsParams> {
final NotificationsRepository repository;
ObtenirNotificationsUseCase(this.repository);
@override
Future<Either<Failure, List<NotificationEntity>>> call(ObtenirNotificationsParams params) async {
// Vérification du cache en premier
final cacheValide = await repository.isCacheValide(
params.utilisateurId,
maxAgeMinutes: params.maxAgeCacheMinutes,
);
if (!cacheValide || params.forceRefresh) {
// Synchronisation avec le serveur si nécessaire
final syncResult = await repository.synchroniser(
params.utilisateurId,
forceSync: params.forceRefresh,
);
// On continue même si la sync échoue (mode offline)
if (syncResult.isLeft()) {
// Log de l'erreur mais on continue avec les données en cache
print('Erreur de synchronisation: ${syncResult.fold((l) => l.toString(), (r) => '')}');
}
}
// Récupération des notifications
return await repository.obtenirNotifications(
utilisateurId: params.utilisateurId,
includeArchivees: params.includeArchivees,
limite: params.limite,
offset: params.offset,
);
}
}
/// Paramètres pour obtenir les notifications
class ObtenirNotificationsParams {
final String utilisateurId;
final bool includeArchivees;
final int limite;
final int offset;
final bool forceRefresh;
final int maxAgeCacheMinutes;
const ObtenirNotificationsParams({
required this.utilisateurId,
this.includeArchivees = false,
this.limite = 50,
this.offset = 0,
this.forceRefresh = false,
this.maxAgeCacheMinutes = 5,
});
ObtenirNotificationsParams copyWith({
String? utilisateurId,
bool? includeArchivees,
int? limite,
int? offset,
bool? forceRefresh,
int? maxAgeCacheMinutes,
}) {
return ObtenirNotificationsParams(
utilisateurId: utilisateurId ?? this.utilisateurId,
includeArchivees: includeArchivees ?? this.includeArchivees,
limite: limite ?? this.limite,
offset: offset ?? this.offset,
forceRefresh: forceRefresh ?? this.forceRefresh,
maxAgeCacheMinutes: maxAgeCacheMinutes ?? this.maxAgeCacheMinutes,
);
}
@override
String toString() {
return 'ObtenirNotificationsParams{utilisateurId: $utilisateurId, includeArchivees: $includeArchivees, limite: $limite, offset: $offset, forceRefresh: $forceRefresh}';
}
}
/// Use case pour obtenir les notifications non lues
class ObtenirNotificationsNonLuesUseCase implements UseCase<List<NotificationEntity>, String> {
final NotificationsRepository repository;
ObtenirNotificationsNonLuesUseCase(this.repository);
@override
Future<Either<Failure, List<NotificationEntity>>> call(String utilisateurId) async {
return await repository.obtenirNotificationsNonLues(utilisateurId);
}
}
/// Use case pour obtenir le nombre de notifications non lues
class ObtenirNombreNonLuesUseCase implements UseCase<int, String> {
final NotificationsRepository repository;
ObtenirNombreNonLuesUseCase(this.repository);
@override
Future<Either<Failure, int>> call(String utilisateurId) async {
return await repository.obtenirNombreNonLues(utilisateurId);
}
}
/// Use case pour rechercher des notifications
class RechercherNotificationsUseCase implements UseCase<List<NotificationEntity>, RechercherNotificationsParams> {
final NotificationsRepository repository;
RechercherNotificationsUseCase(this.repository);
@override
Future<Either<Failure, List<NotificationEntity>>> call(RechercherNotificationsParams params) async {
return await repository.rechercherNotifications(
utilisateurId: params.utilisateurId,
query: params.query,
types: params.types,
statuts: params.statuts,
dateDebut: params.dateDebut,
dateFin: params.dateFin,
limite: params.limite,
);
}
}
/// Paramètres pour la recherche de notifications
class RechercherNotificationsParams {
final String utilisateurId;
final String? query;
final List<TypeNotification>? types;
final List<StatutNotification>? statuts;
final DateTime? dateDebut;
final DateTime? dateFin;
final int limite;
const RechercherNotificationsParams({
required this.utilisateurId,
this.query,
this.types,
this.statuts,
this.dateDebut,
this.dateFin,
this.limite = 50,
});
RechercherNotificationsParams copyWith({
String? utilisateurId,
String? query,
List<TypeNotification>? types,
List<StatutNotification>? statuts,
DateTime? dateDebut,
DateTime? dateFin,
int? limite,
}) {
return RechercherNotificationsParams(
utilisateurId: utilisateurId ?? this.utilisateurId,
query: query ?? this.query,
types: types ?? this.types,
statuts: statuts ?? this.statuts,
dateDebut: dateDebut ?? this.dateDebut,
dateFin: dateFin ?? this.dateFin,
limite: limite ?? this.limite,
);
}
@override
String toString() {
return 'RechercherNotificationsParams{utilisateurId: $utilisateurId, query: $query, types: $types, statuts: $statuts, dateDebut: $dateDebut, dateFin: $dateFin, limite: $limite}';
}
}
/// Use case pour obtenir les notifications par type
class ObtenirNotificationsParTypeUseCase implements UseCase<List<NotificationEntity>, ObtenirNotificationsParTypeParams> {
final NotificationsRepository repository;
ObtenirNotificationsParTypeUseCase(this.repository);
@override
Future<Either<Failure, List<NotificationEntity>>> call(ObtenirNotificationsParTypeParams params) async {
return await repository.obtenirNotificationsParType(
params.utilisateurId,
params.type,
limite: params.limite,
);
}
}
/// Paramètres pour obtenir les notifications par type
class ObtenirNotificationsParTypeParams {
final String utilisateurId;
final TypeNotification type;
final int limite;
const ObtenirNotificationsParTypeParams({
required this.utilisateurId,
required this.type,
this.limite = 50,
});
ObtenirNotificationsParTypeParams copyWith({
String? utilisateurId,
TypeNotification? type,
int? limite,
}) {
return ObtenirNotificationsParTypeParams(
utilisateurId: utilisateurId ?? this.utilisateurId,
type: type ?? this.type,
limite: limite ?? this.limite,
);
}
@override
String toString() {
return 'ObtenirNotificationsParTypeParams{utilisateurId: $utilisateurId, type: $type, limite: $limite}';
}
}
/// Use case pour obtenir les notifications importantes
class ObtenirNotificationsImportantesUseCase implements UseCase<List<NotificationEntity>, String> {
final NotificationsRepository repository;
ObtenirNotificationsImportantesUseCase(this.repository);
@override
Future<Either<Failure, List<NotificationEntity>>> call(String utilisateurId) async {
return await repository.obtenirNotificationsImportantes(utilisateurId);
}
}
/// Use case pour obtenir les statistiques des notifications
class ObtenirStatistiquesNotificationsUseCase implements UseCase<Map<String, dynamic>, ObtenirStatistiquesParams> {
final NotificationsRepository repository;
ObtenirStatistiquesNotificationsUseCase(this.repository);
@override
Future<Either<Failure, Map<String, dynamic>>> call(ObtenirStatistiquesParams params) async {
return await repository.obtenirStatistiques(
params.utilisateurId,
periode: params.periode,
);
}
}
/// Paramètres pour obtenir les statistiques
class ObtenirStatistiquesParams {
final String utilisateurId;
final int periode;
const ObtenirStatistiquesParams({
required this.utilisateurId,
this.periode = 30,
});
ObtenirStatistiquesParams copyWith({
String? utilisateurId,
int? periode,
}) {
return ObtenirStatistiquesParams(
utilisateurId: utilisateurId ?? this.utilisateurId,
periode: periode ?? this.periode,
);
}
@override
String toString() {
return 'ObtenirStatistiquesParams{utilisateurId: $utilisateurId, periode: $periode}';
}
}

View File

@@ -0,0 +1,779 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/widgets/unified_page_layout.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../domain/entities/notification.dart';
import '../../domain/entities/preferences_notification.dart';
import '../bloc/notification_preferences_bloc.dart';
import '../widgets/preference_section_widget.dart';
import '../widgets/silent_mode_config_widget.dart';
/// Page de configuration des préférences de notifications
class NotificationPreferencesPage extends StatefulWidget {
const NotificationPreferencesPage({super.key});
@override
State<NotificationPreferencesPage> createState() => _NotificationPreferencesPageState();
}
class _NotificationPreferencesPageState extends State<NotificationPreferencesPage>
with TickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
// Chargement des préférences
context.read<NotificationPreferencesBloc>().add(
const LoadPreferencesEvent(),
);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return UnifiedPageLayout(
title: 'Préférences de notifications',
showBackButton: true,
actions: [
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
switch (value) {
case 'reset':
_showResetDialog();
break;
case 'test':
_sendTestNotification();
break;
case 'export':
_exportPreferences();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'test',
child: ListTile(
leading: Icon(Icons.send),
title: Text('Envoyer un test'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'export',
child: ListTile(
leading: Icon(Icons.download),
title: Text('Exporter'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'reset',
child: ListTile(
leading: Icon(Icons.restore, color: Colors.red),
title: Text('Réinitialiser', style: TextStyle(color: Colors.red)),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
body: BlocBuilder<NotificationPreferencesBloc, NotificationPreferencesState>(
builder: (context, state) {
if (state is PreferencesLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state is PreferencesError) {
return Center(
child: UnifiedCard(
variant: UnifiedCardVariant.outlined,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 48,
color: AppColors.error,
),
const SizedBox(height: 16),
Text(
'Erreur de chargement',
style: AppTextStyles.titleMedium,
),
const SizedBox(height: 8),
Text(
state.message,
style: AppTextStyles.bodyMedium.copyWith(
color: AppColors.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
context.read<NotificationPreferencesBloc>().add(
const LoadPreferencesEvent(),
);
},
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
),
),
);
}
if (state is! PreferencesLoaded) {
return const SizedBox.shrink();
}
return Column(
children: [
// Onglets de navigation
Container(
margin: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.outline.withOpacity(0.2)),
),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(8),
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: const EdgeInsets.all(4),
labelColor: AppColors.onPrimary,
unselectedLabelColor: AppColors.onSurface,
labelStyle: AppTextStyles.bodyMedium.copyWith(
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: AppTextStyles.bodyMedium,
tabs: const [
Tab(text: 'Général'),
Tab(text: 'Types'),
Tab(text: 'Avancé'),
],
),
),
// Contenu des onglets
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildGeneralTab(state.preferences),
_buildTypesTab(state.preferences),
_buildAdvancedTab(state.preferences),
],
),
),
],
);
},
),
);
}
Widget _buildGeneralTab(PreferencesNotificationEntity preferences) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Activation générale
PreferenceSectionWidget(
title: 'Notifications',
subtitle: 'Paramètres généraux des notifications',
icon: Icons.notifications,
children: [
SwitchListTile(
title: const Text('Activer les notifications'),
subtitle: const Text('Recevoir toutes les notifications'),
value: preferences.notificationsActivees,
onChanged: (value) => _updatePreference(
preferences.copyWith(notificationsActivees: value),
),
),
if (preferences.notificationsActivees) ...[
SwitchListTile(
title: const Text('Notifications push'),
subtitle: const Text('Recevoir les notifications sur l\'appareil'),
value: preferences.pushActivees,
onChanged: (value) => _updatePreference(
preferences.copyWith(pushActivees: value),
),
),
SwitchListTile(
title: const Text('Notifications par email'),
subtitle: const Text('Recevoir les notifications par email'),
value: preferences.emailActivees,
onChanged: (value) => _updatePreference(
preferences.copyWith(emailActivees: value),
),
),
SwitchListTile(
title: const Text('Notifications SMS'),
subtitle: const Text('Recevoir les notifications par SMS'),
value: preferences.smsActivees,
onChanged: (value) => _updatePreference(
preferences.copyWith(smsActivees: value),
),
),
],
],
),
const SizedBox(height: 24),
// Mode silencieux
PreferenceSectionWidget(
title: 'Mode silencieux',
subtitle: 'Configurer les périodes de silence',
icon: Icons.do_not_disturb,
children: [
SilentModeConfigWidget(
preferences: preferences,
onPreferencesChanged: _updatePreference,
),
],
),
const SizedBox(height: 24),
// Paramètres visuels et sonores
PreferenceSectionWidget(
title: 'Apparence et sons',
subtitle: 'Personnaliser l\'affichage des notifications',
icon: Icons.palette,
children: [
SwitchListTile(
title: const Text('Vibration'),
subtitle: const Text('Faire vibrer l\'appareil'),
value: preferences.vibrationActivee,
onChanged: (value) => _updatePreference(
preferences.copyWith(vibrationActivee: value),
),
),
SwitchListTile(
title: const Text('Son'),
subtitle: const Text('Jouer un son'),
value: preferences.sonActive,
onChanged: (value) => _updatePreference(
preferences.copyWith(sonActive: value),
),
),
SwitchListTile(
title: const Text('LED'),
subtitle: const Text('Allumer la LED de notification'),
value: preferences.ledActivee,
onChanged: (value) => _updatePreference(
preferences.copyWith(ledActivee: value),
),
),
SwitchListTile(
title: const Text('Aperçu sur écran verrouillé'),
subtitle: const Text('Afficher le contenu sur l\'écran verrouillé'),
value: preferences.apercuEcranVerrouillage,
onChanged: (value) => _updatePreference(
preferences.copyWith(apercuEcranVerrouillage: value),
),
),
],
),
],
),
);
}
Widget _buildTypesTab(PreferencesNotificationEntity preferences) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Choisissez les types de notifications que vous souhaitez recevoir',
style: AppTextStyles.bodyMedium.copyWith(
color: AppColors.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: 16),
// Groupement par catégorie
..._buildTypesByCategory(preferences),
],
),
);
}
Widget _buildAdvancedTab(PreferencesNotificationEntity preferences) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Gestion automatique
PreferenceSectionWidget(
title: 'Gestion automatique',
subtitle: 'Paramètres de gestion automatique des notifications',
icon: Icons.auto_mode,
children: [
SwitchListTile(
title: const Text('Marquage automatique comme lu'),
subtitle: const Text('Marquer automatiquement les notifications comme lues'),
value: preferences.marquageLectureAutomatique,
onChanged: (value) => _updatePreference(
preferences.copyWith(marquageLectureAutomatique: value),
),
),
if (preferences.marquageLectureAutomatique)
ListTile(
title: const Text('Délai de marquage'),
subtitle: Text('${preferences.delaiMarquageLectureSecondes ?? 5} secondes'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showDelayPicker(
'Délai de marquage automatique',
preferences.delaiMarquageLectureSecondes ?? 5,
(value) => _updatePreference(
preferences.copyWith(delaiMarquageLectureSecondes: value),
),
),
),
SwitchListTile(
title: const Text('Archivage automatique'),
subtitle: const Text('Archiver automatiquement les notifications lues'),
value: preferences.archivageAutomatique,
onChanged: (value) => _updatePreference(
preferences.copyWith(archivageAutomatique: value),
),
),
if (preferences.archivageAutomatique)
ListTile(
title: const Text('Délai d\'archivage'),
subtitle: Text('${preferences.delaiArchivageHeures} heures'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showDelayPicker(
'Délai d\'archivage automatique',
preferences.delaiArchivageHeures,
(value) => _updatePreference(
preferences.copyWith(delaiArchivageHeures: value),
),
),
),
],
),
const SizedBox(height: 24),
// Limites et regroupement
PreferenceSectionWidget(
title: 'Limites et regroupement',
subtitle: 'Contrôler le nombre et le regroupement des notifications',
icon: Icons.group_work,
children: [
ListTile(
title: const Text('Notifications simultanées maximum'),
subtitle: Text('${preferences.maxNotificationsSimultanees} notifications'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showNumberPicker(
'Nombre maximum de notifications simultanées',
preferences.maxNotificationsSimultanees,
1,
50,
(value) => _updatePreference(
preferences.copyWith(maxNotificationsSimultanees: value),
),
),
),
ListTile(
title: const Text('Fréquence de regroupement'),
subtitle: Text('${preferences.frequenceRegroupementMinutes} minutes'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showNumberPicker(
'Fréquence de regroupement des notifications',
preferences.frequenceRegroupementMinutes,
1,
60,
(value) => _updatePreference(
preferences.copyWith(frequenceRegroupementMinutes: value),
),
),
),
ListTile(
title: const Text('Durée d\'affichage'),
subtitle: Text('${preferences.dureeAffichageSecondes} secondes'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showNumberPicker(
'Durée d\'affichage des notifications',
preferences.dureeAffichageSecondes,
3,
30,
(value) => _updatePreference(
preferences.copyWith(dureeAffichageSecondes: value),
),
),
),
],
),
const SizedBox(height: 24),
// Conservation des données
PreferenceSectionWidget(
title: 'Conservation des données',
subtitle: 'Durée de conservation des notifications',
icon: Icons.storage,
children: [
ListTile(
title: const Text('Durée de conservation'),
subtitle: Text('${preferences.dureeConservationJours} jours'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showNumberPicker(
'Durée de conservation des notifications',
preferences.dureeConservationJours,
7,
365,
(value) => _updatePreference(
preferences.copyWith(dureeConservationJours: value),
),
),
),
SwitchListTile(
title: const Text('Affichage de l\'historique'),
subtitle: const Text('Conserver l\'historique des notifications'),
value: preferences.affichageHistorique,
onChanged: (value) => _updatePreference(
preferences.copyWith(affichageHistorique: value),
),
),
],
),
],
),
);
}
List<Widget> _buildTypesByCategory(PreferencesNotificationEntity preferences) {
final typesByCategory = <String, List<TypeNotification>>{};
for (final type in TypeNotification.values) {
typesByCategory.putIfAbsent(type.categorie, () => []).add(type);
}
return typesByCategory.entries.map((entry) {
return PreferenceSectionWidget(
title: _getCategoryTitle(entry.key),
subtitle: _getCategorySubtitle(entry.key),
icon: _getCategoryIcon(entry.key),
children: entry.value.map((type) {
return SwitchListTile(
title: Text(type.libelle),
subtitle: Text(_getTypeDescription(type)),
value: preferences.isTypeActive(type),
onChanged: (value) => _toggleNotificationType(type, value),
secondary: Icon(
_getTypeIconData(type),
color: _getTypeColor(type),
),
);
}).toList(),
);
}).toList();
}
String _getCategoryTitle(String category) {
switch (category) {
case 'evenements':
return 'Événements';
case 'cotisations':
return 'Cotisations';
case 'solidarite':
return 'Solidarité';
case 'membres':
return 'Membres';
case 'organisation':
return 'Organisation';
case 'messages':
return 'Messages';
case 'systeme':
return 'Système';
default:
return category;
}
}
String _getCategorySubtitle(String category) {
switch (category) {
case 'evenements':
return 'Notifications liées aux événements';
case 'cotisations':
return 'Notifications de paiement et cotisations';
case 'solidarite':
return 'Demandes d\'aide et solidarité';
case 'membres':
return 'Nouveaux membres et anniversaires';
case 'organisation':
return 'Annonces et réunions';
case 'messages':
return 'Messages privés et mentions';
case 'systeme':
return 'Mises à jour et maintenance';
default:
return '';
}
}
IconData _getCategoryIcon(String category) {
switch (category) {
case 'evenements':
return Icons.event;
case 'cotisations':
return Icons.payment;
case 'solidarite':
return Icons.volunteer_activism;
case 'membres':
return Icons.people;
case 'organisation':
return Icons.business;
case 'messages':
return Icons.message;
case 'systeme':
return Icons.settings;
default:
return Icons.notifications;
}
}
String _getTypeDescription(TypeNotification type) {
// Descriptions courtes pour chaque type
switch (type) {
case TypeNotification.nouvelEvenement:
return 'Nouveaux événements créés';
case TypeNotification.rappelEvenement:
return 'Rappels avant les événements';
case TypeNotification.cotisationDue:
return 'Échéances de cotisations';
case TypeNotification.cotisationPayee:
return 'Confirmations de paiement';
case TypeNotification.nouvelleDemandeAide:
return 'Nouvelles demandes d\'aide';
case TypeNotification.nouveauMembre:
return 'Nouveaux membres rejoignant';
case TypeNotification.anniversaireMembre:
return 'Anniversaires des membres';
case TypeNotification.annonceGenerale:
return 'Annonces importantes';
case TypeNotification.messagePrive:
return 'Messages privés reçus';
default:
return type.libelle;
}
}
IconData _getTypeIconData(TypeNotification type) {
switch (type.icone) {
case 'event':
return Icons.event;
case 'payment':
return Icons.payment;
case 'help':
return Icons.help;
case 'person_add':
return Icons.person_add;
case 'cake':
return Icons.cake;
case 'campaign':
return Icons.campaign;
case 'mail':
return Icons.mail;
default:
return Icons.notifications;
}
}
Color _getTypeColor(TypeNotification type) {
try {
return Color(int.parse(type.couleur.replaceFirst('#', '0xFF')));
} catch (e) {
return AppColors.primary;
}
}
void _updatePreference(PreferencesNotificationEntity preferences) {
context.read<NotificationPreferencesBloc>().add(
UpdatePreferencesEvent(preferences: preferences),
);
}
void _toggleNotificationType(TypeNotification type, bool active) {
context.read<NotificationPreferencesBloc>().add(
ToggleNotificationTypeEvent(type: type, active: active),
);
}
void _showResetDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Réinitialiser les préférences'),
content: const Text(
'Êtes-vous sûr de vouloir réinitialiser toutes vos préférences '
'de notifications aux valeurs par défaut ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
context.read<NotificationPreferencesBloc>().add(
const ResetPreferencesEvent(),
);
},
style: TextButton.styleFrom(
foregroundColor: AppColors.error,
),
child: const Text('Réinitialiser'),
),
],
),
);
}
void _sendTestNotification() {
context.read<NotificationPreferencesBloc>().add(
const SendTestNotificationEvent(type: TypeNotification.annonceGenerale),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Notification de test envoyée'),
duration: Duration(seconds: 2),
),
);
}
void _exportPreferences() {
context.read<NotificationPreferencesBloc>().add(
const ExportPreferencesEvent(),
);
}
void _showDelayPicker(String title, int currentValue, Function(int) onChanged) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Valeur actuelle: $currentValue secondes'),
const SizedBox(height: 16),
// Ici vous pourriez ajouter un slider ou un picker
// Pour simplifier, on utilise des boutons prédéfinis
Wrap(
spacing: 8,
children: [5, 10, 15, 30, 60].map((value) {
return ChoiceChip(
label: Text('${value}s'),
selected: currentValue == value,
onSelected: (selected) {
if (selected) {
onChanged(value);
Navigator.pop(context);
}
},
);
}).toList(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
],
),
);
}
void _showNumberPicker(
String title,
int currentValue,
int min,
int max,
Function(int) onChanged,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Valeur actuelle: $currentValue'),
const SizedBox(height: 16),
// Slider pour choisir la valeur
Slider(
value: currentValue.toDouble(),
min: min.toDouble(),
max: max.toDouble(),
divisions: max - min,
label: currentValue.toString(),
onChanged: (value) {
// Mise à jour en temps réel
},
onChangeEnd: (value) {
onChanged(value.round());
Navigator.pop(context);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
],
),
);
}
}

View File

@@ -0,0 +1,539 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/widgets/unified_page_layout.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/utils/date_formatter.dart';
import '../../domain/entities/notification.dart';
import '../bloc/notifications_bloc.dart';
import '../widgets/notification_card_widget.dart';
import '../widgets/notification_filter_widget.dart';
import '../widgets/notification_search_widget.dart';
import '../widgets/notification_stats_widget.dart';
/// Page principale du centre de notifications
class NotificationsCenterPage extends StatefulWidget {
const NotificationsCenterPage({super.key});
@override
State<NotificationsCenterPage> createState() => _NotificationsCenterPageState();
}
class _NotificationsCenterPageState extends State<NotificationsCenterPage>
with TickerProviderStateMixin {
late TabController _tabController;
final ScrollController _scrollController = ScrollController();
bool _showSearch = false;
String _searchQuery = '';
Set<TypeNotification> _selectedTypes = {};
Set<StatutNotification> _selectedStatuts = {};
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_scrollController.addListener(_onScroll);
// Chargement initial des notifications
context.read<NotificationsBloc>().add(
const LoadNotificationsEvent(forceRefresh: false),
);
}
@override
void dispose() {
_tabController.dispose();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
// Chargement de plus de notifications (pagination)
context.read<NotificationsBloc>().add(
const LoadMoreNotificationsEvent(),
);
}
}
void _onRefresh() {
context.read<NotificationsBloc>().add(
const LoadNotificationsEvent(forceRefresh: true),
);
}
void _toggleSearch() {
setState(() {
_showSearch = !_showSearch;
if (!_showSearch) {
_searchQuery = '';
_applyFilters();
}
});
}
void _onSearchChanged(String query) {
setState(() {
_searchQuery = query;
});
_applyFilters();
}
void _onFiltersChanged({
Set<TypeNotification>? types,
Set<StatutNotification>? statuts,
}) {
setState(() {
if (types != null) _selectedTypes = types;
if (statuts != null) _selectedStatuts = statuts;
});
_applyFilters();
}
void _applyFilters() {
context.read<NotificationsBloc>().add(
SearchNotificationsEvent(
query: _searchQuery.isEmpty ? null : _searchQuery,
types: _selectedTypes.isEmpty ? null : _selectedTypes.toList(),
statuts: _selectedStatuts.isEmpty ? null : _selectedStatuts.toList(),
),
);
}
void _markAllAsRead() {
context.read<NotificationsBloc>().add(
const MarkAllAsReadEvent(),
);
}
void _archiveAllRead() {
context.read<NotificationsBloc>().add(
const ArchiveAllReadEvent(),
);
}
@override
Widget build(BuildContext context) {
return UnifiedPageLayout(
title: 'Notifications',
showBackButton: true,
actions: [
IconButton(
icon: Icon(_showSearch ? Icons.search_off : Icons.search),
onPressed: _toggleSearch,
tooltip: _showSearch ? 'Fermer la recherche' : 'Rechercher',
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
switch (value) {
case 'mark_all_read':
_markAllAsRead();
break;
case 'archive_all_read':
_archiveAllRead();
break;
case 'preferences':
Navigator.pushNamed(context, '/notifications/preferences');
break;
case 'export':
Navigator.pushNamed(context, '/notifications/export');
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'mark_all_read',
child: ListTile(
leading: Icon(Icons.mark_email_read),
title: Text('Tout marquer comme lu'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'archive_all_read',
child: ListTile(
leading: Icon(Icons.archive),
title: Text('Archiver tout lu'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'preferences',
child: ListTile(
leading: Icon(Icons.settings),
title: Text('Préférences'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'export',
child: ListTile(
leading: Icon(Icons.download),
title: Text('Exporter'),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
body: Column(
children: [
// Barre de recherche (conditionnelle)
if (_showSearch)
Padding(
padding: const EdgeInsets.all(16.0),
child: NotificationSearchWidget(
onSearchChanged: _onSearchChanged,
onFiltersChanged: _onFiltersChanged,
selectedTypes: _selectedTypes,
selectedStatuts: _selectedStatuts,
),
),
// Statistiques rapides
BlocBuilder<NotificationsBloc, NotificationsState>(
builder: (context, state) {
if (state is NotificationsLoaded) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: NotificationStatsWidget(
totalCount: state.notifications.length,
unreadCount: state.unreadCount,
importantCount: state.notifications
.where((n) => n.estImportante)
.length,
),
);
}
return const SizedBox.shrink();
},
),
const SizedBox(height: 16),
// Onglets de filtrage
Container(
margin: const EdgeInsets.symmetric(horizontal: 16.0),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.outline.withOpacity(0.2)),
),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(8),
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: const EdgeInsets.all(4),
labelColor: AppColors.onPrimary,
unselectedLabelColor: AppColors.onSurface,
labelStyle: AppTextStyles.bodyMedium.copyWith(
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: AppTextStyles.bodyMedium,
tabs: const [
Tab(text: 'Toutes'),
Tab(text: 'Non lues'),
Tab(text: 'Importantes'),
Tab(text: 'Archivées'),
],
),
),
const SizedBox(height: 16),
// Liste des notifications
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildNotificationsList(NotificationFilter.all),
_buildNotificationsList(NotificationFilter.unread),
_buildNotificationsList(NotificationFilter.important),
_buildNotificationsList(NotificationFilter.archived),
],
),
),
],
),
);
}
Widget _buildNotificationsList(NotificationFilter filter) {
return BlocBuilder<NotificationsBloc, NotificationsState>(
builder: (context, state) {
if (state is NotificationsLoading && state.notifications.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state is NotificationsError && state.notifications.isEmpty) {
return Center(
child: UnifiedCard(
variant: UnifiedCardVariant.outlined,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 48,
color: AppColors.error,
),
const SizedBox(height: 16),
Text(
'Erreur de chargement',
style: AppTextStyles.titleMedium,
),
const SizedBox(height: 8),
Text(
state.message,
style: AppTextStyles.bodyMedium.copyWith(
color: AppColors.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
),
),
);
}
final notifications = _filterNotifications(
state.notifications,
filter,
);
if (notifications.isEmpty) {
return Center(
child: UnifiedCard(
variant: UnifiedCardVariant.outlined,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getEmptyIcon(filter),
size: 48,
color: AppColors.onSurface.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
_getEmptyTitle(filter),
style: AppTextStyles.titleMedium,
),
const SizedBox(height: 8),
Text(
_getEmptyMessage(filter),
style: AppTextStyles.bodyMedium.copyWith(
color: AppColors.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
return RefreshIndicator(
onRefresh: () async => _onRefresh(),
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16.0),
itemCount: notifications.length + (state.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= notifications.length) {
// Indicateur de chargement pour la pagination
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: CircularProgressIndicator(),
),
);
}
final notification = notifications[index];
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: NotificationCardWidget(
notification: notification,
onTap: () => _onNotificationTap(notification),
onMarkAsRead: () => _onMarkAsRead(notification),
onMarkAsImportant: () => _onMarkAsImportant(notification),
onArchive: () => _onArchive(notification),
onDelete: () => _onDelete(notification),
onActionTap: (action) => _onActionTap(notification, action),
),
);
},
),
);
},
);
}
List<NotificationEntity> _filterNotifications(
List<NotificationEntity> notifications,
NotificationFilter filter,
) {
switch (filter) {
case NotificationFilter.all:
return notifications.where((n) => !n.estArchivee).toList();
case NotificationFilter.unread:
return notifications.where((n) => !n.estLue && !n.estArchivee).toList();
case NotificationFilter.important:
return notifications.where((n) => n.estImportante && !n.estArchivee).toList();
case NotificationFilter.archived:
return notifications.where((n) => n.estArchivee).toList();
}
}
IconData _getEmptyIcon(NotificationFilter filter) {
switch (filter) {
case NotificationFilter.all:
return Icons.notifications_none;
case NotificationFilter.unread:
return Icons.mark_email_read;
case NotificationFilter.important:
return Icons.star_border;
case NotificationFilter.archived:
return Icons.archive;
}
}
String _getEmptyTitle(NotificationFilter filter) {
switch (filter) {
case NotificationFilter.all:
return 'Aucune notification';
case NotificationFilter.unread:
return 'Tout est lu !';
case NotificationFilter.important:
return 'Aucune notification importante';
case NotificationFilter.archived:
return 'Aucune notification archivée';
}
}
String _getEmptyMessage(NotificationFilter filter) {
switch (filter) {
case NotificationFilter.all:
return 'Vous n\'avez encore reçu aucune notification.';
case NotificationFilter.unread:
return 'Toutes vos notifications ont été lues.';
case NotificationFilter.important:
return 'Vous n\'avez aucune notification marquée comme importante.';
case NotificationFilter.archived:
return 'Vous n\'avez aucune notification archivée.';
}
}
void _onNotificationTap(NotificationEntity notification) {
// Marquer comme lue si pas encore lue
if (!notification.estLue) {
_onMarkAsRead(notification);
}
// Navigation vers le détail ou action par défaut
if (notification.actionClic != null) {
Navigator.pushNamed(
context,
notification.actionClic!,
arguments: notification.parametresAction,
);
} else {
Navigator.pushNamed(
context,
'/notifications/detail',
arguments: notification.id,
);
}
}
void _onMarkAsRead(NotificationEntity notification) {
context.read<NotificationsBloc>().add(
MarkAsReadEvent(notificationId: notification.id),
);
}
void _onMarkAsImportant(NotificationEntity notification) {
context.read<NotificationsBloc>().add(
MarkAsImportantEvent(
notificationId: notification.id,
important: !notification.estImportante,
),
);
}
void _onArchive(NotificationEntity notification) {
context.read<NotificationsBloc>().add(
ArchiveNotificationEvent(notificationId: notification.id),
);
}
void _onDelete(NotificationEntity notification) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer la notification'),
content: const Text(
'Êtes-vous sûr de vouloir supprimer cette notification ? '
'Cette action est irréversible.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
context.read<NotificationsBloc>().add(
DeleteNotificationEvent(notificationId: notification.id),
);
},
style: TextButton.styleFrom(
foregroundColor: AppColors.error,
),
child: const Text('Supprimer'),
),
],
),
);
}
void _onActionTap(NotificationEntity notification, ActionNotification action) {
context.read<NotificationsBloc>().add(
ExecuteQuickActionEvent(
notificationId: notification.id,
actionId: action.id,
parameters: action.parametres,
),
);
}
}
/// Énumération des filtres de notification
enum NotificationFilter {
all,
unread,
important,
archived,
}

View File

@@ -0,0 +1,430 @@
import 'package:flutter/material.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/utils/date_formatter.dart';
import '../../domain/entities/notification.dart';
/// Widget de carte pour afficher une notification
class NotificationCardWidget extends StatelessWidget {
final NotificationEntity notification;
final VoidCallback? onTap;
final VoidCallback? onMarkAsRead;
final VoidCallback? onMarkAsImportant;
final VoidCallback? onArchive;
final VoidCallback? onDelete;
final Function(ActionNotification)? onActionTap;
const NotificationCardWidget({
super.key,
required this.notification,
this.onTap,
this.onMarkAsRead,
this.onMarkAsImportant,
this.onArchive,
this.onDelete,
this.onActionTap,
});
@override
Widget build(BuildContext context) {
final isUnread = !notification.estLue;
final isImportant = notification.estImportante;
final isExpired = notification.isExpiree;
return UnifiedCard(
variant: isUnread ? UnifiedCardVariant.elevated : UnifiedCardVariant.outlined,
onTap: onTap,
child: Container(
decoration: BoxDecoration(
border: isUnread
? Border.left(
color: _getTypeColor(),
width: 4,
)
: null,
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec icône, titre et actions
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icône du type de notification
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _getTypeColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
_getTypeIcon(),
color: _getTypeColor(),
size: 20,
),
),
const SizedBox(width: 12),
// Titre et métadonnées
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
notification.titre,
style: AppTextStyles.titleSmall.copyWith(
fontWeight: isUnread ? FontWeight.w600 : FontWeight.w500,
color: isExpired
? AppColors.onSurface.withOpacity(0.6)
: AppColors.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// Badges de statut
if (isImportant) ...[
const SizedBox(width: 8),
Icon(
Icons.star,
color: AppColors.warning,
size: 16,
),
],
if (isUnread) ...[
const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
),
],
],
),
const SizedBox(height: 4),
// Métadonnées (expéditeur, date)
Row(
children: [
if (notification.expediteurNom != null) ...[
Text(
notification.expediteurNom!,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.onSurface.withOpacity(0.7),
fontWeight: FontWeight.w500,
),
),
Text(
'',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.onSurface.withOpacity(0.5),
),
),
],
Text(
notification.tempsEcoule,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.onSurface.withOpacity(0.7),
),
),
if (isExpired) ...[
Text(
' • Expirée',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.error,
fontWeight: FontWeight.w500,
),
),
],
],
),
],
),
),
// Menu d'actions
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
color: AppColors.onSurface.withOpacity(0.6),
size: 20,
),
onSelected: (value) => _handleMenuAction(value),
itemBuilder: (context) => [
if (!notification.estLue)
const PopupMenuItem(
value: 'mark_read',
child: ListTile(
leading: Icon(Icons.mark_email_read, size: 20),
title: Text('Marquer comme lu'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'mark_important',
child: ListTile(
leading: Icon(
notification.estImportante ? Icons.star : Icons.star_border,
size: 20,
),
title: Text(
notification.estImportante
? 'Retirer des importantes'
: 'Marquer comme importante',
),
contentPadding: EdgeInsets.zero,
),
),
if (!notification.estArchivee)
const PopupMenuItem(
value: 'archive',
child: ListTile(
leading: Icon(Icons.archive, size: 20),
title: Text('Archiver'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'delete',
child: ListTile(
leading: Icon(Icons.delete, size: 20, color: Colors.red),
title: Text('Supprimer', style: TextStyle(color: Colors.red)),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
const SizedBox(height: 12),
// Message de la notification
Text(
notification.messageAffichage,
style: AppTextStyles.bodyMedium.copyWith(
color: isExpired
? AppColors.onSurface.withOpacity(0.6)
: AppColors.onSurface.withOpacity(0.8),
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
// Image de la notification (si présente)
if (notification.imageUrl != null) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
notification.imageUrl!,
height: 120,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
height: 120,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.image_not_supported,
color: AppColors.onSurface.withOpacity(0.5),
),
),
),
),
],
// Actions rapides
if (notification.hasActionsRapides) ...[
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: notification.actionsRapidesActives
.take(3) // Limite à 3 actions pour éviter l'encombrement
.map((action) => _buildActionButton(action))
.toList(),
),
],
// Tags (si présents)
if (notification.tags != null && notification.tags!.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 6,
children: notification.tags!
.take(3) // Limite à 3 tags
.map((tag) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Text(
tag,
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.onSurfaceVariant,
),
),
))
.toList(),
),
],
],
),
),
),
);
}
Widget _buildActionButton(ActionNotification action) {
return OutlinedButton.icon(
onPressed: () => onActionTap?.call(action),
icon: Icon(
_getActionIcon(action.icone),
size: 16,
),
label: Text(
action.libelle,
style: AppTextStyles.labelMedium,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: action.couleur != null
? Color(int.parse(action.couleur!.replaceFirst('#', '0xFF')))
: AppColors.primary,
side: BorderSide(
color: action.couleur != null
? Color(int.parse(action.couleur!.replaceFirst('#', '0xFF')))
: AppColors.primary,
),
),
);
}
Color _getTypeColor() {
try {
return Color(int.parse(notification.couleurType.replaceFirst('#', '0xFF')));
} catch (e) {
return AppColors.primary;
}
}
IconData _getTypeIcon() {
switch (notification.typeNotification.icone) {
case 'event':
return Icons.event;
case 'payment':
return Icons.payment;
case 'help':
return Icons.help;
case 'person_add':
return Icons.person_add;
case 'cake':
return Icons.cake;
case 'campaign':
return Icons.campaign;
case 'mail':
return Icons.mail;
case 'system_update':
return Icons.system_update;
case 'build':
return Icons.build;
case 'schedule':
return Icons.schedule;
case 'event_busy':
return Icons.event_busy;
case 'check_circle':
return Icons.check_circle;
case 'paid':
return Icons.paid;
case 'error':
return Icons.error;
case 'thumb_up':
return Icons.thumb_up;
case 'volunteer_activism':
return Icons.volunteer_activism;
case 'groups':
return Icons.groups;
case 'alternate_email':
return Icons.alternate_email;
default:
return Icons.notifications;
}
}
IconData _getActionIcon(String? iconeName) {
if (iconeName == null) return Icons.touch_app;
switch (iconeName) {
case 'visibility':
return Icons.visibility;
case 'event_available':
return Icons.event_available;
case 'directions':
return Icons.directions;
case 'payment':
return Icons.payment;
case 'schedule':
return Icons.schedule;
case 'receipt':
return Icons.receipt;
case 'person':
return Icons.person;
case 'message':
return Icons.message;
case 'phone':
return Icons.phone;
case 'reply':
return Icons.reply;
default:
return Icons.touch_app;
}
}
void _handleMenuAction(String action) {
switch (action) {
case 'mark_read':
onMarkAsRead?.call();
break;
case 'mark_important':
onMarkAsImportant?.call();
break;
case 'archive':
onArchive?.call();
break;
case 'delete':
onDelete?.call();
break;
}
}
}

View File

@@ -0,0 +1,389 @@
import 'package:flutter/material.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../domain/entities/notification.dart';
/// Widget de recherche et filtrage des notifications
class NotificationSearchWidget extends StatefulWidget {
final Function(String) onSearchChanged;
final Function({
Set<TypeNotification>? types,
Set<StatutNotification>? statuts,
}) onFiltersChanged;
final Set<TypeNotification> selectedTypes;
final Set<StatutNotification> selectedStatuts;
const NotificationSearchWidget({
super.key,
required this.onSearchChanged,
required this.onFiltersChanged,
required this.selectedTypes,
required this.selectedStatuts,
});
@override
State<NotificationSearchWidget> createState() => _NotificationSearchWidgetState();
}
class _NotificationSearchWidgetState extends State<NotificationSearchWidget> {
final TextEditingController _searchController = TextEditingController();
bool _showFilters = false;
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return UnifiedCard(
variant: UnifiedCardVariant.outlined,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre de recherche
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher dans les notifications...',
hintStyle: AppTextStyles.bodyMedium.copyWith(
color: AppColors.onSurface.withOpacity(0.6),
),
prefixIcon: Icon(
Icons.search,
color: AppColors.onSurface.withOpacity(0.6),
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
widget.onSearchChanged('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: AppColors.outline.withOpacity(0.3),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: AppColors.outline.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: AppColors.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: widget.onSearchChanged,
),
),
const SizedBox(width: 12),
// Bouton de filtres
IconButton(
onPressed: () {
setState(() {
_showFilters = !_showFilters;
});
},
icon: Icon(
Icons.filter_list,
color: _hasActiveFilters()
? AppColors.primary
: AppColors.onSurface.withOpacity(0.6),
),
style: IconButton.styleFrom(
backgroundColor: _hasActiveFilters()
? AppColors.primary.withOpacity(0.1)
: AppColors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: _hasActiveFilters()
? AppColors.primary
: AppColors.outline.withOpacity(0.3),
),
),
),
),
],
),
// Panneau de filtres (conditionnel)
if (_showFilters) ...[
const SizedBox(height: 16),
_buildFiltersPanel(),
],
],
),
),
);
}
Widget _buildFiltersPanel() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête des filtres
Row(
children: [
Text(
'Filtres',
style: AppTextStyles.titleSmall.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (_hasActiveFilters())
TextButton(
onPressed: _clearAllFilters,
child: Text(
'Tout effacer',
style: AppTextStyles.labelMedium.copyWith(
color: AppColors.primary,
),
),
),
],
),
const SizedBox(height: 12),
// Filtres par type
Text(
'Types de notification',
style: AppTextStyles.labelLarge.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _getPopularTypes()
.map((type) => _buildTypeChip(type))
.toList(),
),
const SizedBox(height: 16),
// Filtres par statut
Text(
'Statuts',
style: AppTextStyles.labelLarge.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _getPopularStatuts()
.map((statut) => _buildStatutChip(statut))
.toList(),
),
const SizedBox(height: 16),
// Boutons d'action
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
setState(() {
_showFilters = false;
});
},
child: const Text('Fermer'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () {
setState(() {
_showFilters = false;
});
// Les filtres sont déjà appliqués en temps réel
},
child: const Text('Appliquer'),
),
),
],
),
],
);
}
Widget _buildTypeChip(TypeNotification type) {
final isSelected = widget.selectedTypes.contains(type);
return FilterChip(
label: Text(
type.libelle,
style: AppTextStyles.labelMedium.copyWith(
color: isSelected ? AppColors.onPrimary : AppColors.onSurface,
),
),
selected: isSelected,
onSelected: (selected) {
final newTypes = Set<TypeNotification>.from(widget.selectedTypes);
if (selected) {
newTypes.add(type);
} else {
newTypes.remove(type);
}
widget.onFiltersChanged(types: newTypes);
},
selectedColor: AppColors.primary,
backgroundColor: AppColors.surface,
side: BorderSide(
color: isSelected
? AppColors.primary
: AppColors.outline.withOpacity(0.3),
),
avatar: isSelected
? null
: Icon(
_getTypeIcon(type),
size: 16,
color: _getTypeColor(type),
),
);
}
Widget _buildStatutChip(StatutNotification statut) {
final isSelected = widget.selectedStatuts.contains(statut);
return FilterChip(
label: Text(
statut.libelle,
style: AppTextStyles.labelMedium.copyWith(
color: isSelected ? AppColors.onPrimary : AppColors.onSurface,
),
),
selected: isSelected,
onSelected: (selected) {
final newStatuts = Set<StatutNotification>.from(widget.selectedStatuts);
if (selected) {
newStatuts.add(statut);
} else {
newStatuts.remove(statut);
}
widget.onFiltersChanged(statuts: newStatuts);
},
selectedColor: AppColors.primary,
backgroundColor: AppColors.surface,
side: BorderSide(
color: isSelected
? AppColors.primary
: AppColors.outline.withOpacity(0.3),
),
avatar: isSelected
? null
: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _getStatutColor(statut),
shape: BoxShape.circle,
),
),
);
}
List<TypeNotification> _getPopularTypes() {
return [
TypeNotification.nouvelEvenement,
TypeNotification.cotisationDue,
TypeNotification.nouvelleDemandeAide,
TypeNotification.nouveauMembre,
TypeNotification.annonceGenerale,
TypeNotification.messagePrive,
];
}
List<StatutNotification> _getPopularStatuts() {
return [
StatutNotification.nonLue,
StatutNotification.lue,
StatutNotification.marqueeImportante,
StatutNotification.archivee,
];
}
IconData _getTypeIcon(TypeNotification type) {
switch (type.icone) {
case 'event':
return Icons.event;
case 'payment':
return Icons.payment;
case 'help':
return Icons.help;
case 'person_add':
return Icons.person_add;
case 'campaign':
return Icons.campaign;
case 'mail':
return Icons.mail;
default:
return Icons.notifications;
}
}
Color _getTypeColor(TypeNotification type) {
try {
return Color(int.parse(type.couleur.replaceFirst('#', '0xFF')));
} catch (e) {
return AppColors.primary;
}
}
Color _getStatutColor(StatutNotification statut) {
try {
return Color(int.parse(statut.couleur.replaceFirst('#', '0xFF')));
} catch (e) {
return AppColors.primary;
}
}
bool _hasActiveFilters() {
return widget.selectedTypes.isNotEmpty || widget.selectedStatuts.isNotEmpty;
}
void _clearAllFilters() {
widget.onFiltersChanged(
types: <TypeNotification>{},
statuts: <StatutNotification>{},
);
}
}

View File

@@ -0,0 +1,400 @@
import 'package:flutter/material.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
/// Widget d'affichage des statistiques de notifications
class NotificationStatsWidget extends StatelessWidget {
final int totalCount;
final int unreadCount;
final int importantCount;
const NotificationStatsWidget({
super.key,
required this.totalCount,
required this.unreadCount,
required this.importantCount,
});
@override
Widget build(BuildContext context) {
return UnifiedCard(
variant: UnifiedCardVariant.filled,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
// Statistique principale - Non lues
Expanded(
child: _buildStatItem(
icon: Icons.mark_email_unread,
label: 'Non lues',
value: unreadCount.toString(),
color: unreadCount > 0 ? AppColors.primary : AppColors.onSurface.withOpacity(0.6),
isHighlighted: unreadCount > 0,
),
),
// Séparateur
Container(
width: 1,
height: 40,
color: AppColors.outline.withOpacity(0.2),
),
// Statistique secondaire - Importantes
Expanded(
child: _buildStatItem(
icon: Icons.star,
label: 'Importantes',
value: importantCount.toString(),
color: importantCount > 0 ? AppColors.warning : AppColors.onSurface.withOpacity(0.6),
isHighlighted: importantCount > 0,
),
),
// Séparateur
Container(
width: 1,
height: 40,
color: AppColors.outline.withOpacity(0.2),
),
// Statistique tertiaire - Total
Expanded(
child: _buildStatItem(
icon: Icons.notifications,
label: 'Total',
value: totalCount.toString(),
color: AppColors.onSurface.withOpacity(0.8),
isHighlighted: false,
),
),
],
),
),
);
}
Widget _buildStatItem({
required IconData icon,
required String label,
required String value,
required Color color,
required bool isHighlighted,
}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Icône avec badge si mis en évidence
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 18,
),
),
if (isHighlighted && value != '0')
Positioned(
right: -4,
top: -4,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: AppColors.surface,
width: 2,
),
),
),
),
],
),
const SizedBox(height: 8),
// Valeur
Text(
value,
style: AppTextStyles.titleMedium.copyWith(
color: color,
fontWeight: isHighlighted ? FontWeight.w700 : FontWeight.w600,
),
),
const SizedBox(height: 2),
// Label
Text(
label,
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
);
}
}
/// Widget d'affichage des statistiques détaillées
class DetailedNotificationStatsWidget extends StatelessWidget {
final Map<String, dynamic> stats;
const DetailedNotificationStatsWidget({
super.key,
required this.stats,
});
@override
Widget build(BuildContext context) {
final totalNotifications = stats['total'] ?? 0;
final unreadNotifications = stats['unread'] ?? 0;
final importantNotifications = stats['important'] ?? 0;
final archivedNotifications = stats['archived'] ?? 0;
final todayNotifications = stats['today'] ?? 0;
final weekNotifications = stats['week'] ?? 0;
final engagementRate = stats['engagement_rate'] ?? 0.0;
return UnifiedCard(
variant: UnifiedCardVariant.outlined,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête
Row(
children: [
Icon(
Icons.analytics,
color: AppColors.primary,
size: 24,
),
const SizedBox(width: 12),
Text(
'Statistiques détaillées',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 20),
// Grille de statistiques
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 2.5,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
children: [
_buildDetailedStatCard(
'Total',
totalNotifications.toString(),
Icons.notifications,
AppColors.primary,
),
_buildDetailedStatCard(
'Non lues',
unreadNotifications.toString(),
Icons.mark_email_unread,
AppColors.warning,
),
_buildDetailedStatCard(
'Importantes',
importantNotifications.toString(),
Icons.star,
AppColors.error,
),
_buildDetailedStatCard(
'Archivées',
archivedNotifications.toString(),
Icons.archive,
AppColors.onSurface.withOpacity(0.6),
),
],
),
const SizedBox(height: 20),
// Statistiques temporelles
Row(
children: [
Expanded(
child: _buildTimeStatCard(
'Aujourd\'hui',
todayNotifications.toString(),
Icons.today,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildTimeStatCard(
'Cette semaine',
weekNotifications.toString(),
Icons.date_range,
),
),
],
),
const SizedBox(height: 20),
// Taux d'engagement
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.primary.withOpacity(0.2),
),
),
child: Row(
children: [
Icon(
Icons.trending_up,
color: AppColors.primary,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Taux d\'engagement',
style: AppTextStyles.labelMedium.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
'Pourcentage de notifications ouvertes',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.onSurface.withOpacity(0.7),
),
),
],
),
),
Text(
'${engagementRate.toStringAsFixed(1)}%',
style: AppTextStyles.titleMedium.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w700,
),
),
],
),
),
],
),
),
);
}
Widget _buildDetailedStatCard(
String label,
String value,
IconData icon,
Color color,
) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: color.withOpacity(0.2),
),
),
child: Row(
children: [
Icon(
icon,
color: color,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
value,
style: AppTextStyles.titleSmall.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
Text(
label,
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.onSurface.withOpacity(0.7),
),
),
],
),
),
],
),
);
}
Widget _buildTimeStatCard(String label, String value, IconData icon) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.outline.withOpacity(0.2),
),
),
child: Column(
children: [
Icon(
icon,
color: AppColors.onSurfaceVariant,
size: 20,
),
const SizedBox(height: 8),
Text(
value,
style: AppTextStyles.titleSmall.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
label,
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,366 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../core/performance/performance_optimizer.dart';
import '../../../../core/performance/smart_cache_service.dart';
import '../../../../shared/widgets/performance/optimized_list_view.dart';
import '../../../../shared/theme/app_theme.dart';
/// Page de démonstration des optimisations de performance
class PerformanceDemoPage extends StatefulWidget {
const PerformanceDemoPage({super.key});
@override
State<PerformanceDemoPage> createState() => _PerformanceDemoPageState();
}
class _PerformanceDemoPageState extends State<PerformanceDemoPage>
with TickerProviderStateMixin {
final _optimizer = PerformanceOptimizer();
final _cacheService = SmartCacheService();
// Données de test pour les démonstrations
List<DemoItem> _items = [];
bool _isLoading = false;
bool _hasMore = true;
// Contrôleurs d'animation
late AnimationController _fadeController;
late AnimationController _slideController;
@override
void initState() {
super.initState();
// Initialiser le service de cache
_cacheService.initialize();
// Initialiser les contrôleurs d'animation optimisés
_fadeController = PerformanceOptimizer.createOptimizedController(
duration: const Duration(milliseconds: 500),
vsync: this,
debugLabel: 'FadeController',
);
_slideController = PerformanceOptimizer.createOptimizedController(
duration: const Duration(milliseconds: 300),
vsync: this,
debugLabel: 'SlideController',
);
// Démarrer le monitoring des performances
_optimizer.startPerformanceMonitoring();
// Générer des données initiales
_generateInitialData();
// Démarrer les animations
_fadeController.forward();
_slideController.forward();
}
@override
void dispose() {
PerformanceOptimizer.disposeControllers([_fadeController, _slideController]);
super.dispose();
}
void _generateInitialData() {
_items = List.generate(20, (index) => DemoItem(
id: index,
title: 'Élément $index',
subtitle: 'Description de l\'élément $index',
value: (index * 10).toDouble(),
));
}
Future<void> _loadMoreItems() async {
if (_isLoading || !_hasMore) return;
setState(() {
_isLoading = true;
});
_optimizer.startTimer('load_more_items');
// Simuler un délai de chargement
await Future.delayed(const Duration(milliseconds: 800));
final newItems = List.generate(10, (index) => DemoItem(
id: _items.length + index,
title: 'Élément ${_items.length + index}',
subtitle: 'Description de l\'élément ${_items.length + index}',
value: ((_items.length + index) * 10).toDouble(),
));
setState(() {
_items.addAll(newItems);
_isLoading = false;
_hasMore = _items.length < 100; // Limiter à 100 éléments
});
_optimizer.stopTimer('load_more_items');
}
Future<void> _refreshItems() async {
_optimizer.startTimer('refresh_items');
// Simuler un délai de rafraîchissement
await Future.delayed(const Duration(milliseconds: 500));
setState(() {
_generateInitialData();
_hasMore = true;
});
_optimizer.stopTimer('refresh_items');
}
void _testCachePerformance() async {
_optimizer.startTimer('cache_test');
// Test d'écriture en cache
for (int i = 0; i < 100; i++) {
await _cacheService.put('test_key_$i', 'test_value_$i');
}
// Test de lecture en cache
for (int i = 0; i < 100; i++) {
await _cacheService.get<String>('test_key_$i');
}
_optimizer.stopTimer('cache_test');
final cacheInfo = await _cacheService.getCacheInfo();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Test de cache terminé: $cacheInfo'),
backgroundColor: AppTheme.successColor,
),
);
}
}
void _showPerformanceStats() {
final stats = _optimizer.getPerformanceStats();
final cacheStats = _cacheService.getStats();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Statistiques de Performance'),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Optimiseur:', style: TextStyle(fontWeight: FontWeight.bold)),
...stats.entries.map((e) => Text('${e.key}: ${e.value}')),
const SizedBox(height: 16),
const Text('Cache:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(cacheStats.toString()),
],
),
),
actions: [
TextButton(
onPressed: () {
_optimizer.resetStats();
Navigator.of(context).pop();
},
child: const Text('Réinitialiser'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
);
}
void _clearAllCaches() {
_optimizer.clearAllCaches();
_cacheService.clear();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tous les caches ont été vidés'),
backgroundColor: AppTheme.warningColor,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Démonstration Performance'),
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.analytics),
onPressed: _showPerformanceStats,
tooltip: 'Statistiques',
),
IconButton(
icon: const Icon(Icons.clear_all),
onPressed: _clearAllCaches,
tooltip: 'Vider les caches',
),
],
),
body: FadeTransition(
opacity: _fadeController,
child: Column(
children: [
// Section des boutons de test
Container(
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: _testCachePerformance,
icon: const Icon(Icons.speed),
label: const Text('Test Cache'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
ElevatedButton.icon(
onPressed: () {
HapticFeedback.lightImpact();
PerformanceOptimizer.forceGarbageCollection();
},
icon: const Icon(Icons.cleaning_services),
label: const Text('Force GC'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.warningColor,
foregroundColor: Colors.white,
),
),
ElevatedButton.icon(
onPressed: _showPerformanceStats,
icon: const Icon(Icons.bar_chart),
label: const Text('Stats'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.infoColor,
foregroundColor: Colors.white,
),
),
],
),
),
// Liste optimisée
Expanded(
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController,
curve: Curves.easeOutCubic,
)),
child: OptimizedListView<DemoItem>(
items: _items,
itemBuilder: (context, item, index) => _buildDemoItem(item, index),
onLoadMore: _loadMoreItems,
onRefresh: _refreshItems,
hasMore: _hasMore,
isLoading: _isLoading,
loadMoreThreshold: 5,
itemExtent: 80,
enableAnimations: true,
enableRecycling: true,
maxCachedWidgets: 30,
emptyWidget: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.speed, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('Aucun élément de test', style: TextStyle(color: Colors.grey)),
],
),
),
),
),
),
],
),
),
);
}
Widget _buildDemoItem(DemoItem item, int index) {
return PerformanceOptimizer.optimizeWidget(
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: AppTheme.primaryColor,
child: Text('${item.id}', style: const TextStyle(color: Colors.white)),
),
title: Text(item.title),
subtitle: Text(item.subtitle),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${item.value.toInt()}',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
const Text('pts', style: TextStyle(fontSize: 12)),
],
),
onTap: () {
HapticFeedback.selectionClick();
_optimizer.incrementCounter('item_tapped');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Élément ${item.title} sélectionné'),
duration: const Duration(milliseconds: 800),
),
);
},
),
),
key: 'demo_item_${item.id}',
forceRepaintBoundary: true,
);
}
}
/// Modèle de données pour la démonstration
class DemoItem {
final int id;
final String title;
final String subtitle;
final double value;
DemoItem({
required this.id,
required this.title,
required this.subtitle,
required this.value,
});
@override
int get hashCode => id.hashCode;
@override
bool operator ==(Object other) {
return other is DemoItem && other.id == id;
}
}

View File

@@ -0,0 +1,435 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../core/error/exceptions.dart';
import '../models/demande_aide_model.dart';
import '../models/proposition_aide_model.dart';
import '../models/evaluation_aide_model.dart';
/// Source de données locale pour le module solidarité
///
/// Cette classe gère le cache local des données de solidarité
/// pour permettre un fonctionnement hors ligne et améliorer les performances.
abstract class SolidariteLocalDataSource {
// Cache des demandes d'aide
Future<void> cacherDemandeAide(DemandeAideModel demande);
Future<DemandeAideModel?> obtenirDemandeAideCachee(String id);
Future<List<DemandeAideModel>> obtenirDemandesAideCachees();
Future<void> supprimerDemandeAideCachee(String id);
Future<void> viderCacheDemandesAide();
// Cache des propositions d'aide
Future<void> cacherPropositionAide(PropositionAideModel proposition);
Future<PropositionAideModel?> obtenirPropositionAideCachee(String id);
Future<List<PropositionAideModel>> obtenirPropositionsAideCachees();
Future<void> supprimerPropositionAideCachee(String id);
Future<void> viderCachePropositionsAide();
// Cache des évaluations
Future<void> cacherEvaluation(EvaluationAideModel evaluation);
Future<EvaluationAideModel?> obtenirEvaluationCachee(String id);
Future<List<EvaluationAideModel>> obtenirEvaluationsCachees();
Future<void> supprimerEvaluationCachee(String id);
Future<void> viderCacheEvaluations();
// Cache des statistiques
Future<void> cacherStatistiques(String organisationId, Map<String, dynamic> statistiques);
Future<Map<String, dynamic>?> obtenirStatistiquesCachees(String organisationId);
Future<void> supprimerStatistiquesCachees(String organisationId);
// Gestion du cache
Future<DateTime?> obtenirDateDerniereMiseAJour(String cacheKey);
Future<void> marquerMiseAJour(String cacheKey);
Future<bool> estCacheExpire(String cacheKey, Duration dureeValidite);
Future<void> viderToutCache();
}
/// Implémentation de la source de données locale
class SolidariteLocalDataSourceImpl implements SolidariteLocalDataSource {
final SharedPreferences sharedPreferences;
// Clés de cache
static const String _demandesAideKey = 'CACHED_DEMANDES_AIDE';
static const String _propositionsAideKey = 'CACHED_PROPOSITIONS_AIDE';
static const String _evaluationsKey = 'CACHED_EVALUATIONS';
static const String _statistiquesKey = 'CACHED_STATISTIQUES';
static const String _lastUpdatePrefix = 'LAST_UPDATE_';
// Durées de validité du cache
static const Duration _dureeValiditeDefaut = Duration(minutes: 15);
static const Duration _dureeValiditeStatistiques = Duration(hours: 1);
SolidariteLocalDataSourceImpl({required this.sharedPreferences});
// Cache des demandes d'aide
@override
Future<void> cacherDemandeAide(DemandeAideModel demande) async {
try {
final demandes = await obtenirDemandesAideCachees();
// Supprimer l'ancienne version si elle existe
demandes.removeWhere((d) => d.id == demande.id);
// Ajouter la nouvelle version
demandes.add(demande);
// Limiter le cache à 100 demandes maximum
if (demandes.length > 100) {
demandes.sort((a, b) => b.dateModification.compareTo(a.dateModification));
demandes.removeRange(100, demandes.length);
}
final jsonList = demandes.map((d) => d.toJson()).toList();
await sharedPreferences.setString(_demandesAideKey, jsonEncode(jsonList));
await marquerMiseAJour(_demandesAideKey);
} catch (e) {
throw CacheException(message: 'Erreur lors de la mise en cache de la demande: ${e.toString()}');
}
}
@override
Future<DemandeAideModel?> obtenirDemandeAideCachee(String id) async {
try {
final demandes = await obtenirDemandesAideCachees();
return demandes.cast<DemandeAideModel?>().firstWhere(
(d) => d?.id == id,
orElse: () => null,
);
} catch (e) {
return null;
}
}
@override
Future<List<DemandeAideModel>> obtenirDemandesAideCachees() async {
try {
final jsonString = sharedPreferences.getString(_demandesAideKey);
if (jsonString == null) return [];
final List<dynamic> jsonList = jsonDecode(jsonString);
return jsonList.map((json) => DemandeAideModel.fromJson(json)).toList();
} catch (e) {
return [];
}
}
@override
Future<void> supprimerDemandeAideCachee(String id) async {
try {
final demandes = await obtenirDemandesAideCachees();
demandes.removeWhere((d) => d.id == id);
final jsonList = demandes.map((d) => d.toJson()).toList();
await sharedPreferences.setString(_demandesAideKey, jsonEncode(jsonList));
} catch (e) {
throw CacheException(message: 'Erreur lors de la suppression de la demande du cache: ${e.toString()}');
}
}
@override
Future<void> viderCacheDemandesAide() async {
try {
await sharedPreferences.remove(_demandesAideKey);
await sharedPreferences.remove('$_lastUpdatePrefix$_demandesAideKey');
} catch (e) {
throw CacheException(message: 'Erreur lors de la suppression du cache des demandes: ${e.toString()}');
}
}
// Cache des propositions d'aide
@override
Future<void> cacherPropositionAide(PropositionAideModel proposition) async {
try {
final propositions = await obtenirPropositionsAideCachees();
// Supprimer l'ancienne version si elle existe
propositions.removeWhere((p) => p.id == proposition.id);
// Ajouter la nouvelle version
propositions.add(proposition);
// Limiter le cache à 100 propositions maximum
if (propositions.length > 100) {
propositions.sort((a, b) => b.dateModification.compareTo(a.dateModification));
propositions.removeRange(100, propositions.length);
}
final jsonList = propositions.map((p) => p.toJson()).toList();
await sharedPreferences.setString(_propositionsAideKey, jsonEncode(jsonList));
await marquerMiseAJour(_propositionsAideKey);
} catch (e) {
throw CacheException(message: 'Erreur lors de la mise en cache de la proposition: ${e.toString()}');
}
}
@override
Future<PropositionAideModel?> obtenirPropositionAideCachee(String id) async {
try {
final propositions = await obtenirPropositionsAideCachees();
return propositions.cast<PropositionAideModel?>().firstWhere(
(p) => p?.id == id,
orElse: () => null,
);
} catch (e) {
return null;
}
}
@override
Future<List<PropositionAideModel>> obtenirPropositionsAideCachees() async {
try {
final jsonString = sharedPreferences.getString(_propositionsAideKey);
if (jsonString == null) return [];
final List<dynamic> jsonList = jsonDecode(jsonString);
return jsonList.map((json) => PropositionAideModel.fromJson(json)).toList();
} catch (e) {
return [];
}
}
@override
Future<void> supprimerPropositionAideCachee(String id) async {
try {
final propositions = await obtenirPropositionsAideCachees();
propositions.removeWhere((p) => p.id == id);
final jsonList = propositions.map((p) => p.toJson()).toList();
await sharedPreferences.setString(_propositionsAideKey, jsonEncode(jsonList));
} catch (e) {
throw CacheException(message: 'Erreur lors de la suppression de la proposition du cache: ${e.toString()}');
}
}
@override
Future<void> viderCachePropositionsAide() async {
try {
await sharedPreferences.remove(_propositionsAideKey);
await sharedPreferences.remove('$_lastUpdatePrefix$_propositionsAideKey');
} catch (e) {
throw CacheException(message: 'Erreur lors de la suppression du cache des propositions: ${e.toString()}');
}
}
// Cache des évaluations
@override
Future<void> cacherEvaluation(EvaluationAideModel evaluation) async {
try {
final evaluations = await obtenirEvaluationsCachees();
// Supprimer l'ancienne version si elle existe
evaluations.removeWhere((e) => e.id == evaluation.id);
// Ajouter la nouvelle version
evaluations.add(evaluation);
// Limiter le cache à 200 évaluations maximum
if (evaluations.length > 200) {
evaluations.sort((a, b) => b.dateModification.compareTo(a.dateModification));
evaluations.removeRange(200, evaluations.length);
}
final jsonList = evaluations.map((e) => e.toJson()).toList();
await sharedPreferences.setString(_evaluationsKey, jsonEncode(jsonList));
await marquerMiseAJour(_evaluationsKey);
} catch (e) {
throw CacheException(message: 'Erreur lors de la mise en cache de l\'évaluation: ${e.toString()}');
}
}
@override
Future<EvaluationAideModel?> obtenirEvaluationCachee(String id) async {
try {
final evaluations = await obtenirEvaluationsCachees();
return evaluations.cast<EvaluationAideModel?>().firstWhere(
(e) => e?.id == id,
orElse: () => null,
);
} catch (e) {
return null;
}
}
@override
Future<List<EvaluationAideModel>> obtenirEvaluationsCachees() async {
try {
final jsonString = sharedPreferences.getString(_evaluationsKey);
if (jsonString == null) return [];
final List<dynamic> jsonList = jsonDecode(jsonString);
return jsonList.map((json) => EvaluationAideModel.fromJson(json)).toList();
} catch (e) {
return [];
}
}
@override
Future<void> supprimerEvaluationCachee(String id) async {
try {
final evaluations = await obtenirEvaluationsCachees();
evaluations.removeWhere((e) => e.id == id);
final jsonList = evaluations.map((e) => e.toJson()).toList();
await sharedPreferences.setString(_evaluationsKey, jsonEncode(jsonList));
} catch (e) {
throw CacheException(message: 'Erreur lors de la suppression de l\'évaluation du cache: ${e.toString()}');
}
}
@override
Future<void> viderCacheEvaluations() async {
try {
await sharedPreferences.remove(_evaluationsKey);
await sharedPreferences.remove('$_lastUpdatePrefix$_evaluationsKey');
} catch (e) {
throw CacheException(message: 'Erreur lors de la suppression du cache des évaluations: ${e.toString()}');
}
}
// Cache des statistiques
@override
Future<void> cacherStatistiques(String organisationId, Map<String, dynamic> statistiques) async {
try {
final key = '$_statistiquesKey$organisationId';
await sharedPreferences.setString(key, jsonEncode(statistiques));
await marquerMiseAJour(key);
} catch (e) {
throw CacheException(message: 'Erreur lors de la mise en cache des statistiques: ${e.toString()}');
}
}
@override
Future<Map<String, dynamic>?> obtenirStatistiquesCachees(String organisationId) async {
try {
final key = '$_statistiquesKey$organisationId';
final jsonString = sharedPreferences.getString(key);
if (jsonString == null) return null;
return Map<String, dynamic>.from(jsonDecode(jsonString));
} catch (e) {
return null;
}
}
@override
Future<void> supprimerStatistiquesCachees(String organisationId) async {
try {
final key = '$_statistiquesKey$organisationId';
await sharedPreferences.remove(key);
await sharedPreferences.remove('$_lastUpdatePrefix$key');
} catch (e) {
throw CacheException(message: 'Erreur lors de la suppression des statistiques du cache: ${e.toString()}');
}
}
// Gestion du cache
@override
Future<DateTime?> obtenirDateDerniereMiseAJour(String cacheKey) async {
try {
final timestamp = sharedPreferences.getInt('$_lastUpdatePrefix$cacheKey');
if (timestamp == null) return null;
return DateTime.fromMillisecondsSinceEpoch(timestamp);
} catch (e) {
return null;
}
}
@override
Future<void> marquerMiseAJour(String cacheKey) async {
try {
final timestamp = DateTime.now().millisecondsSinceEpoch;
await sharedPreferences.setInt('$_lastUpdatePrefix$cacheKey', timestamp);
} catch (e) {
throw CacheException(message: 'Erreur lors de la mise à jour du timestamp: ${e.toString()}');
}
}
@override
Future<bool> estCacheExpire(String cacheKey, Duration dureeValidite) async {
try {
final dateDerniereMiseAJour = await obtenirDateDerniereMiseAJour(cacheKey);
if (dateDerniereMiseAJour == null) return true;
final maintenant = DateTime.now();
final dureeEcoulee = maintenant.difference(dateDerniereMiseAJour);
return dureeEcoulee > dureeValidite;
} catch (e) {
return true; // En cas d'erreur, considérer le cache comme expiré
}
}
@override
Future<void> viderToutCache() async {
try {
await Future.wait([
viderCacheDemandesAide(),
viderCachePropositionsAide(),
viderCacheEvaluations(),
]);
// Supprimer toutes les statistiques cachées
final keys = sharedPreferences.getKeys();
final statistiquesKeys = keys.where((key) => key.startsWith(_statistiquesKey));
for (final key in statistiquesKeys) {
await sharedPreferences.remove(key);
await sharedPreferences.remove('$_lastUpdatePrefix$key');
}
} catch (e) {
throw CacheException(message: 'Erreur lors de la suppression complète du cache: ${e.toString()}');
}
}
/// Méthodes utilitaires pour la gestion du cache
/// Vérifie si le cache des demandes est valide
Future<bool> estCacheDemandesValide() async {
return !(await estCacheExpire(_demandesAideKey, _dureeValiditeDefaut));
}
/// Vérifie si le cache des propositions est valide
Future<bool> estCachePropositionsValide() async {
return !(await estCacheExpire(_propositionsAideKey, _dureeValiditeDefaut));
}
/// Vérifie si le cache des évaluations est valide
Future<bool> estCacheEvaluationsValide() async {
return !(await estCacheExpire(_evaluationsKey, _dureeValiditeDefaut));
}
/// Vérifie si le cache des statistiques est valide
Future<bool> estCacheStatistiquesValide(String organisationId) async {
final key = '$_statistiquesKey$organisationId';
return !(await estCacheExpire(key, _dureeValiditeStatistiques));
}
/// Obtient la taille approximative du cache en octets
Future<int> obtenirTailleCache() async {
try {
int taille = 0;
final demandes = sharedPreferences.getString(_demandesAideKey);
if (demandes != null) taille += demandes.length;
final propositions = sharedPreferences.getString(_propositionsAideKey);
if (propositions != null) taille += propositions.length;
final evaluations = sharedPreferences.getString(_evaluationsKey);
if (evaluations != null) taille += evaluations.length;
// Ajouter les statistiques
final keys = sharedPreferences.getKeys();
final statistiquesKeys = keys.where((key) => key.startsWith(_statistiquesKey));
for (final key in statistiquesKeys) {
final value = sharedPreferences.getString(key);
if (value != null) taille += value.length;
}
return taille;
} catch (e) {
return 0;
}
}
}

View File

@@ -0,0 +1,817 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../../core/error/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../models/demande_aide_model.dart';
import '../models/proposition_aide_model.dart';
import '../models/evaluation_aide_model.dart';
/// Source de données distante pour le module solidarité
///
/// Cette classe gère toutes les communications avec l'API REST
/// du backend UnionFlow pour les fonctionnalités de solidarité.
abstract class SolidariteRemoteDataSource {
// Demandes d'aide
Future<DemandeAideModel> creerDemandeAide(DemandeAideModel demande);
Future<DemandeAideModel> mettreAJourDemandeAide(DemandeAideModel demande);
Future<DemandeAideModel> obtenirDemandeAide(String id);
Future<DemandeAideModel> soumettreDemande(String demandeId);
Future<DemandeAideModel> evaluerDemande({
required String demandeId,
required String evaluateurId,
required String decision,
String? commentaire,
double? montantApprouve,
});
Future<List<DemandeAideModel>> rechercherDemandes({
String? organisationId,
String? typeAide,
String? statut,
String? demandeurId,
bool? urgente,
int page = 0,
int taille = 20,
});
Future<List<DemandeAideModel>> obtenirDemandesUrgentes(String organisationId);
Future<List<DemandeAideModel>> obtenirMesdemandes(String utilisateurId);
// Propositions d'aide
Future<PropositionAideModel> creerPropositionAide(PropositionAideModel proposition);
Future<PropositionAideModel> mettreAJourPropositionAide(PropositionAideModel proposition);
Future<PropositionAideModel> obtenirPropositionAide(String id);
Future<PropositionAideModel> changerStatutProposition({
required String propositionId,
required bool activer,
});
Future<List<PropositionAideModel>> rechercherPropositions({
String? organisationId,
String? typeAide,
String? proposantId,
bool? actives,
int page = 0,
int taille = 20,
});
Future<List<PropositionAideModel>> obtenirPropositionsActives(String typeAide);
Future<List<PropositionAideModel>> obtenirMeilleuresPropositions(int limite);
Future<List<PropositionAideModel>> obtenirMesPropositions(String utilisateurId);
// Matching
Future<List<PropositionAideModel>> trouverPropositionsCompatibles(String demandeId);
Future<List<DemandeAideModel>> trouverDemandesCompatibles(String propositionId);
Future<List<PropositionAideModel>> rechercherProposantsFinanciers(String demandeId);
// Évaluations
Future<EvaluationAideModel> creerEvaluation(EvaluationAideModel evaluation);
Future<EvaluationAideModel> mettreAJourEvaluation(EvaluationAideModel evaluation);
Future<EvaluationAideModel> obtenirEvaluation(String id);
Future<List<EvaluationAideModel>> obtenirEvaluationsDemande(String demandeId);
Future<List<EvaluationAideModel>> obtenirEvaluationsProposition(String propositionId);
Future<EvaluationAideModel> signalerEvaluation({
required String evaluationId,
required String motif,
});
Future<StatistiquesEvaluationModel> calculerMoyenneDemande(String demandeId);
Future<StatistiquesEvaluationModel> calculerMoyenneProposition(String propositionId);
// Statistiques
Future<Map<String, dynamic>> obtenirStatistiquesSolidarite(String organisationId);
}
/// Implémentation de la source de données distante
class SolidariteRemoteDataSourceImpl implements SolidariteRemoteDataSource {
final ApiClient apiClient;
static const String baseEndpoint = '/api/solidarite';
SolidariteRemoteDataSourceImpl({required this.apiClient});
// Demandes d'aide
@override
Future<DemandeAideModel> creerDemandeAide(DemandeAideModel demande) async {
try {
final response = await apiClient.post(
'$baseEndpoint/demandes',
data: demande.toJson(),
);
if (response.statusCode == 201) {
return DemandeAideModel.fromJson(response.data);
} else {
throw ServerException(
message: 'Erreur lors de la création de la demande d\'aide',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<DemandeAideModel> mettreAJourDemandeAide(DemandeAideModel demande) async {
try {
final response = await apiClient.put(
'$baseEndpoint/demandes/${demande.id}',
data: demande.toJson(),
);
if (response.statusCode == 200) {
return DemandeAideModel.fromJson(response.data);
} else {
throw ServerException(
message: 'Erreur lors de la mise à jour de la demande d\'aide',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<DemandeAideModel> obtenirDemandeAide(String id) async {
try {
final response = await apiClient.get('$baseEndpoint/demandes/$id');
if (response.statusCode == 200) {
return DemandeAideModel.fromJson(response.data);
} else if (response.statusCode == 404) {
throw NotFoundException(message: 'Demande d\'aide non trouvée');
} else {
throw ServerException(
message: 'Erreur lors de la récupération de la demande d\'aide',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException || e is NotFoundException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<DemandeAideModel> soumettreDemande(String demandeId) async {
try {
final response = await apiClient.post(
'$baseEndpoint/demandes/$demandeId/soumettre',
);
if (response.statusCode == 200) {
return DemandeAideModel.fromJson(response.data);
} else {
throw ServerException(
message: 'Erreur lors de la soumission de la demande',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<DemandeAideModel> evaluerDemande({
required String demandeId,
required String evaluateurId,
required String decision,
String? commentaire,
double? montantApprouve,
}) async {
try {
final data = {
'evaluateurId': evaluateurId,
'decision': decision,
if (commentaire != null) 'commentaire': commentaire,
if (montantApprouve != null) 'montantApprouve': montantApprouve,
};
final response = await apiClient.post(
'$baseEndpoint/demandes/$demandeId/evaluer',
data: data,
);
if (response.statusCode == 200) {
return DemandeAideModel.fromJson(response.data);
} else {
throw ServerException(
message: 'Erreur lors de l\'évaluation de la demande',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<List<DemandeAideModel>> rechercherDemandes({
String? organisationId,
String? typeAide,
String? statut,
String? demandeurId,
bool? urgente,
int page = 0,
int taille = 20,
}) async {
try {
final queryParams = <String, dynamic>{
'page': page,
'size': taille,
if (organisationId != null) 'organisationId': organisationId,
if (typeAide != null) 'typeAide': typeAide,
if (statut != null) 'statut': statut,
if (demandeurId != null) 'demandeurId': demandeurId,
if (urgente != null) 'urgente': urgente,
};
final response = await apiClient.get(
'$baseEndpoint/demandes/rechercher',
queryParameters: queryParams,
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data['content'];
return data.map((json) => DemandeAideModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Erreur lors de la recherche des demandes',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<List<DemandeAideModel>> obtenirDemandesUrgentes(String organisationId) async {
try {
final response = await apiClient.get(
'$baseEndpoint/demandes/urgentes',
queryParameters: {'organisationId': organisationId},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => DemandeAideModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Erreur lors de la récupération des demandes urgentes',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<List<DemandeAideModel>> obtenirMesdemandes(String utilisateurId) async {
try {
final response = await apiClient.get(
'$baseEndpoint/demandes/mes-demandes',
queryParameters: {'utilisateurId': utilisateurId},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => DemandeAideModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Erreur lors de la récupération de vos demandes',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
// Propositions d'aide
@override
Future<PropositionAideModel> creerPropositionAide(PropositionAideModel proposition) async {
try {
final response = await apiClient.post(
'$baseEndpoint/propositions',
data: proposition.toJson(),
);
if (response.statusCode == 201) {
return PropositionAideModel.fromJson(response.data);
} else {
throw ServerException(
message: 'Erreur lors de la création de la proposition d\'aide',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<PropositionAideModel> mettreAJourPropositionAide(PropositionAideModel proposition) async {
try {
final response = await apiClient.put(
'$baseEndpoint/propositions/${proposition.id}',
data: proposition.toJson(),
);
if (response.statusCode == 200) {
return PropositionAideModel.fromJson(response.data);
} else {
throw ServerException(
message: 'Erreur lors de la mise à jour de la proposition d\'aide',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<PropositionAideModel> obtenirPropositionAide(String id) async {
try {
final response = await apiClient.get('$baseEndpoint/propositions/$id');
if (response.statusCode == 200) {
return PropositionAideModel.fromJson(response.data);
} else if (response.statusCode == 404) {
throw NotFoundException(message: 'Proposition d\'aide non trouvée');
} else {
throw ServerException(
message: 'Erreur lors de la récupération de la proposition d\'aide',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException || e is NotFoundException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<PropositionAideModel> changerStatutProposition({
required String propositionId,
required bool activer,
}) async {
try {
final endpoint = activer ? 'activer' : 'desactiver';
final response = await apiClient.post(
'$baseEndpoint/propositions/$propositionId/$endpoint',
);
if (response.statusCode == 200) {
return PropositionAideModel.fromJson(response.data);
} else {
throw ServerException(
message: 'Erreur lors du changement de statut de la proposition',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<List<PropositionAideModel>> rechercherPropositions({
String? organisationId,
String? typeAide,
String? proposantId,
bool? actives,
int page = 0,
int taille = 20,
}) async {
try {
final queryParams = <String, dynamic>{
'page': page,
'size': taille,
if (organisationId != null) 'organisationId': organisationId,
if (typeAide != null) 'typeAide': typeAide,
if (proposantId != null) 'proposantId': proposantId,
if (actives != null) 'actives': actives,
};
final response = await apiClient.get(
'$baseEndpoint/propositions/rechercher',
queryParameters: queryParams,
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data['content'];
return data.map((json) => PropositionAideModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Erreur lors de la recherche des propositions',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<List<PropositionAideModel>> obtenirPropositionsActives(String typeAide) async {
try {
final response = await apiClient.get(
'$baseEndpoint/propositions/actives',
queryParameters: {'typeAide': typeAide},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => PropositionAideModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Erreur lors de la récupération des propositions actives',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<List<PropositionAideModel>> obtenirMeilleuresPropositions(int limite) async {
try {
final response = await apiClient.get(
'$baseEndpoint/propositions/meilleures',
queryParameters: {'limite': limite},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => PropositionAideModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Erreur lors de la récupération des meilleures propositions',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<List<PropositionAideModel>> obtenirMesPropositions(String utilisateurId) async {
try {
final response = await apiClient.get(
'$baseEndpoint/propositions/mes-propositions',
queryParameters: {'utilisateurId': utilisateurId},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => PropositionAideModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Erreur lors de la récupération de vos propositions',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
// Matching
@override
Future<List<PropositionAideModel>> trouverPropositionsCompatibles(String demandeId) async {
try {
final response = await apiClient.get(
'$baseEndpoint/matching/propositions-compatibles/$demandeId',
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => PropositionAideModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Erreur lors de la recherche de propositions compatibles',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<List<DemandeAideModel>> trouverDemandesCompatibles(String propositionId) async {
try {
final response = await apiClient.get(
'$baseEndpoint/matching/demandes-compatibles/$propositionId',
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => DemandeAideModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Erreur lors de la recherche de demandes compatibles',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<List<PropositionAideModel>> rechercherProposantsFinanciers(String demandeId) async {
try {
final response = await apiClient.get(
'$baseEndpoint/matching/proposants-financiers/$demandeId',
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => PropositionAideModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Erreur lors de la recherche de proposants financiers',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
// Évaluations
@override
Future<EvaluationAideModel> creerEvaluation(EvaluationAideModel evaluation) async {
try {
final response = await apiClient.post(
'$baseEndpoint/evaluations',
data: evaluation.toJson(),
);
if (response.statusCode == 201) {
return EvaluationAideModel.fromJson(response.data);
} else {
throw ServerException(
message: 'Erreur lors de la création de l\'évaluation',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<EvaluationAideModel> mettreAJourEvaluation(EvaluationAideModel evaluation) async {
try {
final response = await apiClient.put(
'$baseEndpoint/evaluations/${evaluation.id}',
data: evaluation.toJson(),
);
if (response.statusCode == 200) {
return EvaluationAideModel.fromJson(response.data);
} else {
throw ServerException(
message: 'Erreur lors de la mise à jour de l\'évaluation',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<EvaluationAideModel> obtenirEvaluation(String id) async {
try {
final response = await apiClient.get('$baseEndpoint/evaluations/$id');
if (response.statusCode == 200) {
return EvaluationAideModel.fromJson(response.data);
} else if (response.statusCode == 404) {
throw NotFoundException(message: 'Évaluation non trouvée');
} else {
throw ServerException(
message: 'Erreur lors de la récupération de l\'évaluation',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException || e is NotFoundException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<List<EvaluationAideModel>> obtenirEvaluationsDemande(String demandeId) async {
try {
final response = await apiClient.get(
'$baseEndpoint/evaluations/demande/$demandeId',
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => EvaluationAideModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Erreur lors de la récupération des évaluations de la demande',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<List<EvaluationAideModel>> obtenirEvaluationsProposition(String propositionId) async {
try {
final response = await apiClient.get(
'$baseEndpoint/evaluations/proposition/$propositionId',
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((json) => EvaluationAideModel.fromJson(json)).toList();
} else {
throw ServerException(
message: 'Erreur lors de la récupération des évaluations de la proposition',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<EvaluationAideModel> signalerEvaluation({
required String evaluationId,
required String motif,
}) async {
try {
final response = await apiClient.post(
'$baseEndpoint/evaluations/$evaluationId/signaler',
data: {'motif': motif},
);
if (response.statusCode == 200) {
return EvaluationAideModel.fromJson(response.data);
} else {
throw ServerException(
message: 'Erreur lors du signalement de l\'évaluation',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<StatistiquesEvaluationModel> calculerMoyenneDemande(String demandeId) async {
try {
final response = await apiClient.get(
'$baseEndpoint/evaluations/moyenne/demande/$demandeId',
);
if (response.statusCode == 200) {
return StatistiquesEvaluationModel.fromJson(response.data);
} else {
throw ServerException(
message: 'Erreur lors du calcul de la moyenne de la demande',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
@override
Future<StatistiquesEvaluationModel> calculerMoyenneProposition(String propositionId) async {
try {
final response = await apiClient.get(
'$baseEndpoint/evaluations/moyenne/proposition/$propositionId',
);
if (response.statusCode == 200) {
return StatistiquesEvaluationModel.fromJson(response.data);
} else {
throw ServerException(
message: 'Erreur lors du calcul de la moyenne de la proposition',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
// Statistiques
@override
Future<Map<String, dynamic>> obtenirStatistiquesSolidarite(String organisationId) async {
try {
final response = await apiClient.get(
'$baseEndpoint/statistiques',
queryParameters: {'organisationId': organisationId},
);
if (response.statusCode == 200) {
return Map<String, dynamic>.from(response.data);
} else {
throw ServerException(
message: 'Erreur lors de la récupération des statistiques',
statusCode: response.statusCode,
);
}
} catch (e) {
if (e is ServerException) rethrow;
throw ServerException(
message: 'Erreur de communication avec le serveur: ${e.toString()}',
);
}
}
}

View File

@@ -0,0 +1,332 @@
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/network_info.dart';
// Domain
import '../domain/repositories/solidarite_repository.dart';
import '../domain/usecases/gerer_demandes_aide_usecase.dart';
import '../domain/usecases/gerer_propositions_aide_usecase.dart';
import '../domain/usecases/gerer_matching_usecase.dart';
import '../domain/usecases/gerer_evaluations_usecase.dart';
import '../domain/usecases/obtenir_statistiques_usecase.dart';
// Data
import 'datasources/solidarite_remote_data_source.dart';
import 'datasources/solidarite_local_data_source.dart';
import 'repositories/solidarite_repository_impl.dart';
/// Configuration de l'injection de dépendances pour le module solidarité
///
/// Cette classe configure tous les services, repositories, use cases
/// et data sources nécessaires au fonctionnement du module solidarité.
class SolidariteInjectionContainer {
static final GetIt _sl = GetIt.instance;
/// Initialise toutes les dépendances du module solidarité
static Future<void> init() async {
// ============================================================================
// Features - Solidarité
// ============================================================================
// Use Cases - Demandes d'aide
_sl.registerLazySingleton(() => CreerDemandeAideUseCase(_sl()));
_sl.registerLazySingleton(() => MettreAJourDemandeAideUseCase(_sl()));
_sl.registerLazySingleton(() => ObtenirDemandeAideUseCase(_sl()));
_sl.registerLazySingleton(() => SoumettreDemandeAideUseCase(_sl()));
_sl.registerLazySingleton(() => EvaluerDemandeAideUseCase(_sl()));
_sl.registerLazySingleton(() => RechercherDemandesAideUseCase(_sl()));
_sl.registerLazySingleton(() => ObtenirDemandesUrgentesUseCase(_sl()));
_sl.registerLazySingleton(() => ObtenirMesDemandesUseCase(_sl()));
_sl.registerLazySingleton(() => ValiderDemandeAideUseCase());
_sl.registerLazySingleton(() => CalculerPrioriteDemandeUseCase());
// Use Cases - Propositions d'aide
_sl.registerLazySingleton(() => CreerPropositionAideUseCase(_sl()));
_sl.registerLazySingleton(() => MettreAJourPropositionAideUseCase(_sl()));
_sl.registerLazySingleton(() => ObtenirPropositionAideUseCase(_sl()));
_sl.registerLazySingleton(() => ChangerStatutPropositionUseCase(_sl()));
_sl.registerLazySingleton(() => RechercherPropositionsAideUseCase(_sl()));
_sl.registerLazySingleton(() => ObtenirPropositionsActivesUseCase(_sl()));
_sl.registerLazySingleton(() => ObtenirMeilleuresPropositionsUseCase(_sl()));
_sl.registerLazySingleton(() => ObtenirMesPropositionsUseCase(_sl()));
_sl.registerLazySingleton(() => ValiderPropositionAideUseCase());
_sl.registerLazySingleton(() => CalculerScorePropositionUseCase());
// Use Cases - Matching
_sl.registerLazySingleton(() => TrouverPropositionsCompatiblesUseCase(_sl()));
_sl.registerLazySingleton(() => TrouverDemandesCompatiblesUseCase(_sl()));
_sl.registerLazySingleton(() => RechercherProposantsFinanciersUseCase(_sl()));
_sl.registerLazySingleton(() => CalculerScoreCompatibiliteUseCase());
_sl.registerLazySingleton(() => EffectuerMatchingIntelligentUseCase(
trouverPropositionsCompatibles: _sl(),
calculerScoreCompatibilite: _sl(),
));
_sl.registerLazySingleton(() => AnalyserTendancesMatchingUseCase());
// Use Cases - Évaluations
_sl.registerLazySingleton(() => CreerEvaluationUseCase(_sl()));
_sl.registerLazySingleton(() => MettreAJourEvaluationUseCase(_sl()));
_sl.registerLazySingleton(() => ObtenirEvaluationUseCase(_sl()));
_sl.registerLazySingleton(() => ObtenirEvaluationsDemandeUseCase(_sl()));
_sl.registerLazySingleton(() => ObtenirEvaluationsPropositionUseCase(_sl()));
_sl.registerLazySingleton(() => SignalerEvaluationUseCase(_sl()));
_sl.registerLazySingleton(() => CalculerMoyenneDemandeUseCase(_sl()));
_sl.registerLazySingleton(() => CalculerMoyennePropositionUseCase(_sl()));
_sl.registerLazySingleton(() => ValiderEvaluationUseCase());
_sl.registerLazySingleton(() => CalculerScoreQualiteEvaluationUseCase());
_sl.registerLazySingleton(() => AnalyserTendancesEvaluationUseCase());
// Use Cases - Statistiques
_sl.registerLazySingleton(() => ObtenirStatistiquesSolidariteUseCase(_sl()));
_sl.registerLazySingleton(() => CalculerKPIsPerformanceUseCase());
_sl.registerLazySingleton(() => GenererRapportActiviteUseCase());
// Repository
_sl.registerLazySingleton<SolidariteRepository>(
() => SolidariteRepositoryImpl(
remoteDataSource: _sl(),
localDataSource: _sl(),
networkInfo: _sl(),
),
);
// Data Sources
_sl.registerLazySingleton<SolidariteRemoteDataSource>(
() => SolidariteRemoteDataSourceImpl(apiClient: _sl()),
);
_sl.registerLazySingleton<SolidariteLocalDataSource>(
() => SolidariteLocalDataSourceImpl(sharedPreferences: _sl()),
);
// ============================================================================
// Core (si pas déjà enregistrés)
// ============================================================================
// Ces services sont normalement enregistrés dans le core injection container
// Nous les enregistrons ici seulement s'ils ne sont pas déjà disponibles
if (!_sl.isRegistered<ApiClient>()) {
_sl.registerLazySingleton<ApiClient>(() => ApiClientImpl());
}
if (!_sl.isRegistered<NetworkInfo>()) {
_sl.registerLazySingleton<NetworkInfo>(() => NetworkInfoImpl());
}
if (!_sl.isRegistered<SharedPreferences>()) {
final sharedPreferences = await SharedPreferences.getInstance();
_sl.registerLazySingleton<SharedPreferences>(() => sharedPreferences);
}
}
/// Nettoie toutes les dépendances du module solidarité
static Future<void> dispose() async {
// Use Cases - Demandes d'aide
_sl.unregister<CreerDemandeAideUseCase>();
_sl.unregister<MettreAJourDemandeAideUseCase>();
_sl.unregister<ObtenirDemandeAideUseCase>();
_sl.unregister<SoumettreDemandeAideUseCase>();
_sl.unregister<EvaluerDemandeAideUseCase>();
_sl.unregister<RechercherDemandesAideUseCase>();
_sl.unregister<ObtenirDemandesUrgentesUseCase>();
_sl.unregister<ObtenirMesDemandesUseCase>();
_sl.unregister<ValiderDemandeAideUseCase>();
_sl.unregister<CalculerPrioriteDemandeUseCase>();
// Use Cases - Propositions d'aide
_sl.unregister<CreerPropositionAideUseCase>();
_sl.unregister<MettreAJourPropositionAideUseCase>();
_sl.unregister<ObtenirPropositionAideUseCase>();
_sl.unregister<ChangerStatutPropositionUseCase>();
_sl.unregister<RechercherPropositionsAideUseCase>();
_sl.unregister<ObtenirPropositionsActivesUseCase>();
_sl.unregister<ObtenirMeilleuresPropositionsUseCase>();
_sl.unregister<ObtenirMesPropositionsUseCase>();
_sl.unregister<ValiderPropositionAideUseCase>();
_sl.unregister<CalculerScorePropositionUseCase>();
// Use Cases - Matching
_sl.unregister<TrouverPropositionsCompatiblesUseCase>();
_sl.unregister<TrouverDemandesCompatiblesUseCase>();
_sl.unregister<RechercherProposantsFinanciersUseCase>();
_sl.unregister<CalculerScoreCompatibiliteUseCase>();
_sl.unregister<EffectuerMatchingIntelligentUseCase>();
_sl.unregister<AnalyserTendancesMatchingUseCase>();
// Use Cases - Évaluations
_sl.unregister<CreerEvaluationUseCase>();
_sl.unregister<MettreAJourEvaluationUseCase>();
_sl.unregister<ObtenirEvaluationUseCase>();
_sl.unregister<ObtenirEvaluationsDemandeUseCase>();
_sl.unregister<ObtenirEvaluationsPropositionUseCase>();
_sl.unregister<SignalerEvaluationUseCase>();
_sl.unregister<CalculerMoyenneDemandeUseCase>();
_sl.unregister<CalculerMoyennePropositionUseCase>();
_sl.unregister<ValiderEvaluationUseCase>();
_sl.unregister<CalculerScoreQualiteEvaluationUseCase>();
_sl.unregister<AnalyserTendancesEvaluationUseCase>();
// Use Cases - Statistiques
_sl.unregister<ObtenirStatistiquesSolidariteUseCase>();
_sl.unregister<CalculerKPIsPerformanceUseCase>();
_sl.unregister<GenererRapportActiviteUseCase>();
// Repository et Data Sources
_sl.unregister<SolidariteRepository>();
_sl.unregister<SolidariteRemoteDataSource>();
_sl.unregister<SolidariteLocalDataSource>();
}
/// Obtient une instance d'un service enregistré
static T get<T extends Object>() => _sl.get<T>();
/// Vérifie si un service est enregistré
static bool isRegistered<T extends Object>() => _sl.isRegistered<T>();
/// Réinitialise complètement le container
static Future<void> reset() async {
await dispose();
await init();
}
/// Obtient des statistiques sur les services enregistrés
static Map<String, dynamic> getStats() {
return {
'totalServices': _sl.allReadySync().length,
'solidariteServices': {
'useCases': {
'demandes': 10,
'propositions': 10,
'matching': 6,
'evaluations': 11,
'statistiques': 3,
},
'repositories': 1,
'dataSources': 2,
},
'isInitialized': _sl.isRegistered<SolidariteRepository>(),
};
}
/// Valide que tous les services critiques sont enregistrés
static bool validateConfiguration() {
try {
// Vérifier les services critiques
final criticalServices = [
SolidariteRepository,
SolidariteRemoteDataSource,
SolidariteLocalDataSource,
CreerDemandeAideUseCase,
CreerPropositionAideUseCase,
CreerEvaluationUseCase,
ObtenirStatistiquesSolidariteUseCase,
];
for (final serviceType in criticalServices) {
if (!_sl.isRegistered(instance: serviceType)) {
return false;
}
}
return true;
} catch (e) {
return false;
}
}
/// Effectue un test de santé des services
static Future<Map<String, bool>> healthCheck() async {
final results = <String, bool>{};
try {
// Test du repository
final repository = _sl.get<SolidariteRepository>();
results['repository'] = repository != null;
// Test des data sources
final remoteDataSource = _sl.get<SolidariteRemoteDataSource>();
results['remoteDataSource'] = remoteDataSource != null;
final localDataSource = _sl.get<SolidariteLocalDataSource>();
results['localDataSource'] = localDataSource != null;
// Test des use cases critiques
final creerDemandeUseCase = _sl.get<CreerDemandeAideUseCase>();
results['creerDemandeUseCase'] = creerDemandeUseCase != null;
final creerPropositionUseCase = _sl.get<CreerPropositionAideUseCase>();
results['creerPropositionUseCase'] = creerPropositionUseCase != null;
final creerEvaluationUseCase = _sl.get<CreerEvaluationUseCase>();
results['creerEvaluationUseCase'] = creerEvaluationUseCase != null;
// Test des services de base
results['networkInfo'] = _sl.isRegistered<NetworkInfo>();
results['apiClient'] = _sl.isRegistered<ApiClient>();
results['sharedPreferences'] = _sl.isRegistered<SharedPreferences>();
} catch (e) {
results['error'] = false;
}
return results;
}
}
/// Extension pour faciliter l'accès aux services depuis les widgets
extension SolidariteServiceLocator on GetIt {
// Use Cases - Demandes d'aide
CreerDemandeAideUseCase get creerDemandeAide => get<CreerDemandeAideUseCase>();
MettreAJourDemandeAideUseCase get mettreAJourDemandeAide => get<MettreAJourDemandeAideUseCase>();
ObtenirDemandeAideUseCase get obtenirDemandeAide => get<ObtenirDemandeAideUseCase>();
SoumettreDemandeAideUseCase get soumettreDemandeAide => get<SoumettreDemandeAideUseCase>();
EvaluerDemandeAideUseCase get evaluerDemandeAide => get<EvaluerDemandeAideUseCase>();
RechercherDemandesAideUseCase get rechercherDemandesAide => get<RechercherDemandesAideUseCase>();
ObtenirDemandesUrgentesUseCase get obtenirDemandesUrgentes => get<ObtenirDemandesUrgentesUseCase>();
ObtenirMesDemandesUseCase get obtenirMesdemandes => get<ObtenirMesDemandesUseCase>();
ValiderDemandeAideUseCase get validerDemandeAide => get<ValiderDemandeAideUseCase>();
CalculerPrioriteDemandeUseCase get calculerPrioriteDemande => get<CalculerPrioriteDemandeUseCase>();
// Use Cases - Propositions d'aide
CreerPropositionAideUseCase get creerPropositionAide => get<CreerPropositionAideUseCase>();
MettreAJourPropositionAideUseCase get mettreAJourPropositionAide => get<MettreAJourPropositionAideUseCase>();
ObtenirPropositionAideUseCase get obtenirPropositionAide => get<ObtenirPropositionAideUseCase>();
ChangerStatutPropositionUseCase get changerStatutProposition => get<ChangerStatutPropositionUseCase>();
RechercherPropositionsAideUseCase get rechercherPropositionsAide => get<RechercherPropositionsAideUseCase>();
ObtenirPropositionsActivesUseCase get obtenirPropositionsActives => get<ObtenirPropositionsActivesUseCase>();
ObtenirMeilleuresPropositionsUseCase get obtenirMeilleuresPropositions => get<ObtenirMeilleuresPropositionsUseCase>();
ObtenirMesPropositionsUseCase get obtenirMesPropositions => get<ObtenirMesPropositionsUseCase>();
ValiderPropositionAideUseCase get validerPropositionAide => get<ValiderPropositionAideUseCase>();
CalculerScorePropositionUseCase get calculerScoreProposition => get<CalculerScorePropositionUseCase>();
// Use Cases - Matching
TrouverPropositionsCompatiblesUseCase get trouverPropositionsCompatibles => get<TrouverPropositionsCompatiblesUseCase>();
TrouverDemandesCompatiblesUseCase get trouverDemandesCompatibles => get<TrouverDemandesCompatiblesUseCase>();
RechercherProposantsFinanciersUseCase get rechercherProposantsFinanciers => get<RechercherProposantsFinanciersUseCase>();
CalculerScoreCompatibiliteUseCase get calculerScoreCompatibilite => get<CalculerScoreCompatibiliteUseCase>();
EffectuerMatchingIntelligentUseCase get effectuerMatchingIntelligent => get<EffectuerMatchingIntelligentUseCase>();
AnalyserTendancesMatchingUseCase get analyserTendancesMatching => get<AnalyserTendancesMatchingUseCase>();
// Use Cases - Évaluations
CreerEvaluationUseCase get creerEvaluation => get<CreerEvaluationUseCase>();
MettreAJourEvaluationUseCase get mettreAJourEvaluation => get<MettreAJourEvaluationUseCase>();
ObtenirEvaluationUseCase get obtenirEvaluation => get<ObtenirEvaluationUseCase>();
ObtenirEvaluationsDemandeUseCase get obtenirEvaluationsDemande => get<ObtenirEvaluationsDemandeUseCase>();
ObtenirEvaluationsPropositionUseCase get obtenirEvaluationsProposition => get<ObtenirEvaluationsPropositionUseCase>();
SignalerEvaluationUseCase get signalerEvaluation => get<SignalerEvaluationUseCase>();
CalculerMoyenneDemandeUseCase get calculerMoyenneDemande => get<CalculerMoyenneDemandeUseCase>();
CalculerMoyennePropositionUseCase get calculerMoyenneProposition => get<CalculerMoyennePropositionUseCase>();
ValiderEvaluationUseCase get validerEvaluation => get<ValiderEvaluationUseCase>();
CalculerScoreQualiteEvaluationUseCase get calculerScoreQualiteEvaluation => get<CalculerScoreQualiteEvaluationUseCase>();
AnalyserTendancesEvaluationUseCase get analyserTendancesEvaluation => get<AnalyserTendancesEvaluationUseCase>();
// Use Cases - Statistiques
ObtenirStatistiquesSolidariteUseCase get obtenirStatistiquesSolidarite => get<ObtenirStatistiquesSolidariteUseCase>();
CalculerKPIsPerformanceUseCase get calculerKPIsPerformance => get<CalculerKPIsPerformanceUseCase>();
GenererRapportActiviteUseCase get genererRapportActivite => get<GenererRapportActiviteUseCase>();
// Repository
SolidariteRepository get solidariteRepository => get<SolidariteRepository>();
}

View File

@@ -0,0 +1,524 @@
import '../../domain/entities/demande_aide.dart';
/// Modèle de données pour les demandes d'aide
///
/// Ce modèle fait la conversion entre les DTOs de l'API REST
/// et les entités du domaine pour les demandes d'aide.
class DemandeAideModel extends DemandeAide {
const DemandeAideModel({
required super.id,
required super.numeroReference,
required super.titre,
required super.description,
required super.typeAide,
required super.statut,
required super.priorite,
required super.demandeurId,
required super.nomDemandeur,
required super.organisationId,
super.montantDemande,
super.montantApprouve,
super.montantVerse,
required super.dateCreation,
required super.dateModification,
super.dateSoumission,
super.dateEvaluation,
super.dateApprobation,
super.dateLimiteTraitement,
super.evaluateurId,
super.commentairesEvaluateur,
super.motifRejet,
super.informationsRequises,
super.justificationUrgence,
super.contactUrgence,
super.localisation,
super.beneficiaires,
super.piecesJustificatives,
super.historiqueStatuts,
super.commentaires,
super.donneesPersonnalisees,
super.estModifiable,
super.estUrgente,
super.delaiDepasse,
super.estTerminee,
});
/// Crée un modèle à partir d'un JSON (API Response)
factory DemandeAideModel.fromJson(Map<String, dynamic> json) {
return DemandeAideModel(
id: json['id'] as String,
numeroReference: json['numeroReference'] as String,
titre: json['titre'] as String,
description: json['description'] as String,
typeAide: _parseTypeAide(json['typeAide'] as String),
statut: _parseStatutAide(json['statut'] as String),
priorite: _parsePrioriteAide(json['priorite'] as String),
demandeurId: json['demandeurId'] as String,
nomDemandeur: json['nomDemandeur'] as String,
organisationId: json['organisationId'] as String,
montantDemande: json['montantDemande']?.toDouble(),
montantApprouve: json['montantApprouve']?.toDouble(),
montantVerse: json['montantVerse']?.toDouble(),
dateCreation: DateTime.parse(json['dateCreation'] as String),
dateModification: DateTime.parse(json['dateModification'] as String),
dateSoumission: json['dateSoumission'] != null
? DateTime.parse(json['dateSoumission'] as String)
: null,
dateEvaluation: json['dateEvaluation'] != null
? DateTime.parse(json['dateEvaluation'] as String)
: null,
dateApprobation: json['dateApprobation'] != null
? DateTime.parse(json['dateApprobation'] as String)
: null,
dateLimiteTraitement: json['dateLimiteTraitement'] != null
? DateTime.parse(json['dateLimiteTraitement'] as String)
: null,
evaluateurId: json['evaluateurId'] as String?,
commentairesEvaluateur: json['commentairesEvaluateur'] as String?,
motifRejet: json['motifRejet'] as String?,
informationsRequises: json['informationsRequises'] as String?,
justificationUrgence: json['justificationUrgence'] as String?,
contactUrgence: json['contactUrgence'] != null
? ContactUrgenceModel.fromJson(json['contactUrgence'] as Map<String, dynamic>)
: null,
localisation: json['localisation'] != null
? LocalisationModel.fromJson(json['localisation'] as Map<String, dynamic>)
: null,
beneficiaires: (json['beneficiaires'] as List<dynamic>?)
?.map((e) => BeneficiaireAideModel.fromJson(e as Map<String, dynamic>))
.toList() ?? [],
piecesJustificatives: (json['piecesJustificatives'] as List<dynamic>?)
?.map((e) => PieceJustificativeModel.fromJson(e as Map<String, dynamic>))
.toList() ?? [],
historiqueStatuts: (json['historiqueStatuts'] as List<dynamic>?)
?.map((e) => HistoriqueStatutModel.fromJson(e as Map<String, dynamic>))
.toList() ?? [],
commentaires: (json['commentaires'] as List<dynamic>?)
?.map((e) => CommentaireAideModel.fromJson(e as Map<String, dynamic>))
.toList() ?? [],
donneesPersonnalisees: Map<String, dynamic>.from(json['donneesPersonnalisees'] ?? {}),
estModifiable: json['estModifiable'] as bool? ?? false,
estUrgente: json['estUrgente'] as bool? ?? false,
delaiDepasse: json['delaiDepasse'] as bool? ?? false,
estTerminee: json['estTerminee'] as bool? ?? false,
);
}
/// Convertit le modèle en JSON (API Request)
Map<String, dynamic> toJson() {
return {
'id': id,
'numeroReference': numeroReference,
'titre': titre,
'description': description,
'typeAide': typeAide.name,
'statut': statut.name,
'priorite': priorite.name,
'demandeurId': demandeurId,
'nomDemandeur': nomDemandeur,
'organisationId': organisationId,
'montantDemande': montantDemande,
'montantApprouve': montantApprouve,
'montantVerse': montantVerse,
'dateCreation': dateCreation.toIso8601String(),
'dateModification': dateModification.toIso8601String(),
'dateSoumission': dateSoumission?.toIso8601String(),
'dateEvaluation': dateEvaluation?.toIso8601String(),
'dateApprobation': dateApprobation?.toIso8601String(),
'dateLimiteTraitement': dateLimiteTraitement?.toIso8601String(),
'evaluateurId': evaluateurId,
'commentairesEvaluateur': commentairesEvaluateur,
'motifRejet': motifRejet,
'informationsRequises': informationsRequises,
'justificationUrgence': justificationUrgence,
'contactUrgence': contactUrgence != null
? (contactUrgence as ContactUrgenceModel).toJson()
: null,
'localisation': localisation != null
? (localisation as LocalisationModel).toJson()
: null,
'beneficiaires': beneficiaires
.map((e) => (e as BeneficiaireAideModel).toJson())
.toList(),
'piecesJustificatives': piecesJustificatives
.map((e) => (e as PieceJustificativeModel).toJson())
.toList(),
'historiqueStatuts': historiqueStatuts
.map((e) => (e as HistoriqueStatutModel).toJson())
.toList(),
'commentaires': commentaires
.map((e) => (e as CommentaireAideModel).toJson())
.toList(),
'donneesPersonnalisees': donneesPersonnalisees,
'estModifiable': estModifiable,
'estUrgente': estUrgente,
'delaiDepasse': delaiDepasse,
'estTerminee': estTerminee,
};
}
/// Crée un modèle à partir d'une entité du domaine
factory DemandeAideModel.fromEntity(DemandeAide entity) {
return DemandeAideModel(
id: entity.id,
numeroReference: entity.numeroReference,
titre: entity.titre,
description: entity.description,
typeAide: entity.typeAide,
statut: entity.statut,
priorite: entity.priorite,
demandeurId: entity.demandeurId,
nomDemandeur: entity.nomDemandeur,
organisationId: entity.organisationId,
montantDemande: entity.montantDemande,
montantApprouve: entity.montantApprouve,
montantVerse: entity.montantVerse,
dateCreation: entity.dateCreation,
dateModification: entity.dateModification,
dateSoumission: entity.dateSoumission,
dateEvaluation: entity.dateEvaluation,
dateApprobation: entity.dateApprobation,
dateLimiteTraitement: entity.dateLimiteTraitement,
evaluateurId: entity.evaluateurId,
commentairesEvaluateur: entity.commentairesEvaluateur,
motifRejet: entity.motifRejet,
informationsRequises: entity.informationsRequises,
justificationUrgence: entity.justificationUrgence,
contactUrgence: entity.contactUrgence != null
? ContactUrgenceModel.fromEntity(entity.contactUrgence!)
: null,
localisation: entity.localisation != null
? LocalisationModel.fromEntity(entity.localisation!)
: null,
beneficiaires: entity.beneficiaires
.map((e) => BeneficiaireAideModel.fromEntity(e))
.toList(),
piecesJustificatives: entity.piecesJustificatives
.map((e) => PieceJustificativeModel.fromEntity(e))
.toList(),
historiqueStatuts: entity.historiqueStatuts
.map((e) => HistoriqueStatutModel.fromEntity(e))
.toList(),
commentaires: entity.commentaires
.map((e) => CommentaireAideModel.fromEntity(e))
.toList(),
donneesPersonnalisees: Map<String, dynamic>.from(entity.donneesPersonnalisees),
estModifiable: entity.estModifiable,
estUrgente: entity.estUrgente,
delaiDepasse: entity.delaiDepasse,
estTerminee: entity.estTerminee,
);
}
/// Convertit le modèle en entité du domaine
DemandeAide toEntity() {
return DemandeAide(
id: id,
numeroReference: numeroReference,
titre: titre,
description: description,
typeAide: typeAide,
statut: statut,
priorite: priorite,
demandeurId: demandeurId,
nomDemandeur: nomDemandeur,
organisationId: organisationId,
montantDemande: montantDemande,
montantApprouve: montantApprouve,
montantVerse: montantVerse,
dateCreation: dateCreation,
dateModification: dateModification,
dateSoumission: dateSoumission,
dateEvaluation: dateEvaluation,
dateApprobation: dateApprobation,
dateLimiteTraitement: dateLimiteTraitement,
evaluateurId: evaluateurId,
commentairesEvaluateur: commentairesEvaluateur,
motifRejet: motifRejet,
informationsRequises: informationsRequises,
justificationUrgence: justificationUrgence,
contactUrgence: contactUrgence,
localisation: localisation,
beneficiaires: beneficiaires,
piecesJustificatives: piecesJustificatives,
historiqueStatuts: historiqueStatuts,
commentaires: commentaires,
donneesPersonnalisees: donneesPersonnalisees,
estModifiable: estModifiable,
estUrgente: estUrgente,
delaiDepasse: delaiDepasse,
estTerminee: estTerminee,
);
}
// Méthodes utilitaires de parsing
static TypeAide _parseTypeAide(String value) {
return TypeAide.values.firstWhere(
(e) => e.name == value,
orElse: () => TypeAide.autre,
);
}
static StatutAide _parseStatutAide(String value) {
return StatutAide.values.firstWhere(
(e) => e.name == value,
orElse: () => StatutAide.brouillon,
);
}
static PrioriteAide _parsePrioriteAide(String value) {
return PrioriteAide.values.firstWhere(
(e) => e.name == value,
orElse: () => PrioriteAide.normale,
);
}
}
/// Modèles pour les classes auxiliaires
class ContactUrgenceModel extends ContactUrgence {
const ContactUrgenceModel({
required super.nom,
required super.telephone,
super.email,
required super.relation,
});
factory ContactUrgenceModel.fromJson(Map<String, dynamic> json) {
return ContactUrgenceModel(
nom: json['nom'] as String,
telephone: json['telephone'] as String,
email: json['email'] as String?,
relation: json['relation'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'nom': nom,
'telephone': telephone,
'email': email,
'relation': relation,
};
}
factory ContactUrgenceModel.fromEntity(ContactUrgence entity) {
return ContactUrgenceModel(
nom: entity.nom,
telephone: entity.telephone,
email: entity.email,
relation: entity.relation,
);
}
}
class LocalisationModel extends Localisation {
const LocalisationModel({
required super.adresse,
required super.ville,
super.codePostal,
super.pays,
super.latitude,
super.longitude,
});
factory LocalisationModel.fromJson(Map<String, dynamic> json) {
return LocalisationModel(
adresse: json['adresse'] as String,
ville: json['ville'] as String,
codePostal: json['codePostal'] as String?,
pays: json['pays'] as String?,
latitude: json['latitude']?.toDouble(),
longitude: json['longitude']?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'adresse': adresse,
'ville': ville,
'codePostal': codePostal,
'pays': pays,
'latitude': latitude,
'longitude': longitude,
};
}
factory LocalisationModel.fromEntity(Localisation entity) {
return LocalisationModel(
adresse: entity.adresse,
ville: entity.ville,
codePostal: entity.codePostal,
pays: entity.pays,
latitude: entity.latitude,
longitude: entity.longitude,
);
}
}
class BeneficiaireAideModel extends BeneficiaireAide {
const BeneficiaireAideModel({
required super.nom,
required super.prenom,
required super.age,
required super.relation,
super.telephone,
});
factory BeneficiaireAideModel.fromJson(Map<String, dynamic> json) {
return BeneficiaireAideModel(
nom: json['nom'] as String,
prenom: json['prenom'] as String,
age: json['age'] as int,
relation: json['relation'] as String,
telephone: json['telephone'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'nom': nom,
'prenom': prenom,
'age': age,
'relation': relation,
'telephone': telephone,
};
}
factory BeneficiaireAideModel.fromEntity(BeneficiaireAide entity) {
return BeneficiaireAideModel(
nom: entity.nom,
prenom: entity.prenom,
age: entity.age,
relation: entity.relation,
telephone: entity.telephone,
);
}
}
class PieceJustificativeModel extends PieceJustificative {
const PieceJustificativeModel({
required super.id,
required super.nom,
required super.type,
required super.url,
required super.taille,
required super.dateAjout,
});
factory PieceJustificativeModel.fromJson(Map<String, dynamic> json) {
return PieceJustificativeModel(
id: json['id'] as String,
nom: json['nom'] as String,
type: json['type'] as String,
url: json['url'] as String,
taille: json['taille'] as int,
dateAjout: DateTime.parse(json['dateAjout'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'nom': nom,
'type': type,
'url': url,
'taille': taille,
'dateAjout': dateAjout.toIso8601String(),
};
}
factory PieceJustificativeModel.fromEntity(PieceJustificative entity) {
return PieceJustificativeModel(
id: entity.id,
nom: entity.nom,
type: entity.type,
url: entity.url,
taille: entity.taille,
dateAjout: entity.dateAjout,
);
}
}
class HistoriqueStatutModel extends HistoriqueStatut {
const HistoriqueStatutModel({
required super.ancienStatut,
required super.nouveauStatut,
required super.dateChangement,
super.commentaire,
super.utilisateurId,
});
factory HistoriqueStatutModel.fromJson(Map<String, dynamic> json) {
return HistoriqueStatutModel(
ancienStatut: DemandeAideModel._parseStatutAide(json['ancienStatut'] as String),
nouveauStatut: DemandeAideModel._parseStatutAide(json['nouveauStatut'] as String),
dateChangement: DateTime.parse(json['dateChangement'] as String),
commentaire: json['commentaire'] as String?,
utilisateurId: json['utilisateurId'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'ancienStatut': ancienStatut.name,
'nouveauStatut': nouveauStatut.name,
'dateChangement': dateChangement.toIso8601String(),
'commentaire': commentaire,
'utilisateurId': utilisateurId,
};
}
factory HistoriqueStatutModel.fromEntity(HistoriqueStatut entity) {
return HistoriqueStatutModel(
ancienStatut: entity.ancienStatut,
nouveauStatut: entity.nouveauStatut,
dateChangement: entity.dateChangement,
commentaire: entity.commentaire,
utilisateurId: entity.utilisateurId,
);
}
}
class CommentaireAideModel extends CommentaireAide {
const CommentaireAideModel({
required super.id,
required super.contenu,
required super.auteurId,
required super.nomAuteur,
required super.dateCreation,
super.estPrive,
});
factory CommentaireAideModel.fromJson(Map<String, dynamic> json) {
return CommentaireAideModel(
id: json['id'] as String,
contenu: json['contenu'] as String,
auteurId: json['auteurId'] as String,
nomAuteur: json['nomAuteur'] as String,
dateCreation: DateTime.parse(json['dateCreation'] as String),
estPrive: json['estPrive'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'contenu': contenu,
'auteurId': auteurId,
'nomAuteur': nomAuteur,
'dateCreation': dateCreation.toIso8601String(),
'estPrive': estPrive,
};
}
factory CommentaireAideModel.fromEntity(CommentaireAide entity) {
return CommentaireAideModel(
id: entity.id,
contenu: entity.contenu,
auteurId: entity.auteurId,
nomAuteur: entity.nomAuteur,
dateCreation: entity.dateCreation,
estPrive: entity.estPrive,
);
}
}

View File

@@ -0,0 +1,388 @@
import '../../domain/entities/evaluation_aide.dart';
/// Modèle de données pour les évaluations d'aide
///
/// Ce modèle fait la conversion entre les DTOs de l'API REST
/// et les entités du domaine pour les évaluations d'aide.
class EvaluationAideModel extends EvaluationAide {
const EvaluationAideModel({
required super.id,
required super.demandeId,
super.propositionId,
required super.evaluateurId,
required super.nomEvaluateur,
required super.typeEvaluateur,
required super.statut,
required super.noteGlobale,
super.noteDelaiReponse,
super.noteCommunication,
super.noteProfessionnalisme,
super.noteRespectEngagements,
required super.commentairePrincipal,
super.pointsPositifs,
super.pointsAmelioration,
super.recommandations,
super.recommande,
required super.dateCreation,
required super.dateModification,
super.dateValidation,
super.validateurId,
super.motifSignalement,
super.nombreSignalements,
super.estModeree,
super.estPublique,
super.donneesPersonnalisees,
});
/// Crée un modèle à partir d'un JSON (API Response)
factory EvaluationAideModel.fromJson(Map<String, dynamic> json) {
return EvaluationAideModel(
id: json['id'] as String,
demandeId: json['demandeId'] as String,
propositionId: json['propositionId'] as String?,
evaluateurId: json['evaluateurId'] as String,
nomEvaluateur: json['nomEvaluateur'] as String,
typeEvaluateur: _parseTypeEvaluateur(json['typeEvaluateur'] as String),
statut: _parseStatutEvaluation(json['statut'] as String),
noteGlobale: json['noteGlobale'].toDouble(),
noteDelaiReponse: json['noteDelaiReponse']?.toDouble(),
noteCommunication: json['noteCommunication']?.toDouble(),
noteProfessionnalisme: json['noteProfessionnalisme']?.toDouble(),
noteRespectEngagements: json['noteRespectEngagements']?.toDouble(),
commentairePrincipal: json['commentairePrincipal'] as String,
pointsPositifs: json['pointsPositifs'] as String?,
pointsAmelioration: json['pointsAmelioration'] as String?,
recommandations: json['recommandations'] as String?,
recommande: json['recommande'] as bool?,
dateCreation: DateTime.parse(json['dateCreation'] as String),
dateModification: DateTime.parse(json['dateModification'] as String),
dateValidation: json['dateValidation'] != null
? DateTime.parse(json['dateValidation'] as String)
: null,
validateurId: json['validateurId'] as String?,
motifSignalement: json['motifSignalement'] as String?,
nombreSignalements: json['nombreSignalements'] as int? ?? 0,
estModeree: json['estModeree'] as bool? ?? false,
estPublique: json['estPublique'] as bool? ?? true,
donneesPersonnalisees: Map<String, dynamic>.from(json['donneesPersonnalisees'] ?? {}),
);
}
/// Convertit le modèle en JSON (API Request)
Map<String, dynamic> toJson() {
return {
'id': id,
'demandeId': demandeId,
'propositionId': propositionId,
'evaluateurId': evaluateurId,
'nomEvaluateur': nomEvaluateur,
'typeEvaluateur': typeEvaluateur.name,
'statut': statut.name,
'noteGlobale': noteGlobale,
'noteDelaiReponse': noteDelaiReponse,
'noteCommunication': noteCommunication,
'noteProfessionnalisme': noteProfessionnalisme,
'noteRespectEngagements': noteRespectEngagements,
'commentairePrincipal': commentairePrincipal,
'pointsPositifs': pointsPositifs,
'pointsAmelioration': pointsAmelioration,
'recommandations': recommandations,
'recommande': recommande,
'dateCreation': dateCreation.toIso8601String(),
'dateModification': dateModification.toIso8601String(),
'dateValidation': dateValidation?.toIso8601String(),
'validateurId': validateurId,
'motifSignalement': motifSignalement,
'nombreSignalements': nombreSignalements,
'estModeree': estModeree,
'estPublique': estPublique,
'donneesPersonnalisees': donneesPersonnalisees,
};
}
/// Crée un modèle à partir d'une entité du domaine
factory EvaluationAideModel.fromEntity(EvaluationAide entity) {
return EvaluationAideModel(
id: entity.id,
demandeId: entity.demandeId,
propositionId: entity.propositionId,
evaluateurId: entity.evaluateurId,
nomEvaluateur: entity.nomEvaluateur,
typeEvaluateur: entity.typeEvaluateur,
statut: entity.statut,
noteGlobale: entity.noteGlobale,
noteDelaiReponse: entity.noteDelaiReponse,
noteCommunication: entity.noteCommunication,
noteProfessionnalisme: entity.noteProfessionnalisme,
noteRespectEngagements: entity.noteRespectEngagements,
commentairePrincipal: entity.commentairePrincipal,
pointsPositifs: entity.pointsPositifs,
pointsAmelioration: entity.pointsAmelioration,
recommandations: entity.recommandations,
recommande: entity.recommande,
dateCreation: entity.dateCreation,
dateModification: entity.dateModification,
dateValidation: entity.dateValidation,
validateurId: entity.validateurId,
motifSignalement: entity.motifSignalement,
nombreSignalements: entity.nombreSignalements,
estModeree: entity.estModeree,
estPublique: entity.estPublique,
donneesPersonnalisees: Map<String, dynamic>.from(entity.donneesPersonnalisees),
);
}
/// Convertit le modèle en entité du domaine
EvaluationAide toEntity() {
return EvaluationAide(
id: id,
demandeId: demandeId,
propositionId: propositionId,
evaluateurId: evaluateurId,
nomEvaluateur: nomEvaluateur,
typeEvaluateur: typeEvaluateur,
statut: statut,
noteGlobale: noteGlobale,
noteDelaiReponse: noteDelaiReponse,
noteCommunication: noteCommunication,
noteProfessionnalisme: noteProfessionnalisme,
noteRespectEngagements: noteRespectEngagements,
commentairePrincipal: commentairePrincipal,
pointsPositifs: pointsPositifs,
pointsAmelioration: pointsAmelioration,
recommandations: recommandations,
recommande: recommande,
dateCreation: dateCreation,
dateModification: dateModification,
dateValidation: dateValidation,
validateurId: validateurId,
motifSignalement: motifSignalement,
nombreSignalements: nombreSignalements,
estModeree: estModeree,
estPublique: estPublique,
donneesPersonnalisees: donneesPersonnalisees,
);
}
// Méthodes utilitaires de parsing
static TypeEvaluateur _parseTypeEvaluateur(String value) {
return TypeEvaluateur.values.firstWhere(
(e) => e.name == value,
orElse: () => TypeEvaluateur.beneficiaire,
);
}
static StatutEvaluation _parseStatutEvaluation(String value) {
return StatutEvaluation.values.firstWhere(
(e) => e.name == value,
orElse: () => StatutEvaluation.brouillon,
);
}
}
/// Modèle pour les statistiques d'évaluation
class StatistiquesEvaluationModel {
final double noteMoyenne;
final int nombreEvaluations;
final Map<int, int> repartitionNotes;
final double pourcentageRecommandations;
final List<EvaluationAideModel> evaluationsRecentes;
final DateTime dateCalcul;
const StatistiquesEvaluationModel({
required this.noteMoyenne,
required this.nombreEvaluations,
required this.repartitionNotes,
required this.pourcentageRecommandations,
required this.evaluationsRecentes,
required this.dateCalcul,
});
/// Crée un modèle à partir d'un JSON (API Response)
factory StatistiquesEvaluationModel.fromJson(Map<String, dynamic> json) {
return StatistiquesEvaluationModel(
noteMoyenne: json['noteMoyenne'].toDouble(),
nombreEvaluations: json['nombreEvaluations'] as int,
repartitionNotes: Map<int, int>.from(json['repartitionNotes']),
pourcentageRecommandations: json['pourcentageRecommandations'].toDouble(),
evaluationsRecentes: (json['evaluationsRecentes'] as List<dynamic>)
.map((e) => EvaluationAideModel.fromJson(e as Map<String, dynamic>))
.toList(),
dateCalcul: DateTime.parse(json['dateCalcul'] as String),
);
}
/// Convertit le modèle en JSON
Map<String, dynamic> toJson() {
return {
'noteMoyenne': noteMoyenne,
'nombreEvaluations': nombreEvaluations,
'repartitionNotes': repartitionNotes,
'pourcentageRecommandations': pourcentageRecommandations,
'evaluationsRecentes': evaluationsRecentes
.map((e) => e.toJson())
.toList(),
'dateCalcul': dateCalcul.toIso8601String(),
};
}
/// Convertit le modèle en entité du domaine
StatistiquesEvaluation toEntity() {
return StatistiquesEvaluation(
noteMoyenne: noteMoyenne,
nombreEvaluations: nombreEvaluations,
repartitionNotes: repartitionNotes,
pourcentageRecommandations: pourcentageRecommandations,
evaluationsRecentes: evaluationsRecentes
.map((e) => e.toEntity())
.toList(),
dateCalcul: dateCalcul,
);
}
/// Crée un modèle à partir d'une entité du domaine
factory StatistiquesEvaluationModel.fromEntity(StatistiquesEvaluation entity) {
return StatistiquesEvaluationModel(
noteMoyenne: entity.noteMoyenne,
nombreEvaluations: entity.nombreEvaluations,
repartitionNotes: Map<int, int>.from(entity.repartitionNotes),
pourcentageRecommandations: entity.pourcentageRecommandations,
evaluationsRecentes: entity.evaluationsRecentes
.map((e) => EvaluationAideModel.fromEntity(e))
.toList(),
dateCalcul: entity.dateCalcul,
);
}
}
/// Modèle pour les réponses de recherche d'évaluations
class RechercheEvaluationsResponse {
final List<EvaluationAideModel> evaluations;
final int totalElements;
final int totalPages;
final int currentPage;
final int pageSize;
final bool hasNext;
final bool hasPrevious;
const RechercheEvaluationsResponse({
required this.evaluations,
required this.totalElements,
required this.totalPages,
required this.currentPage,
required this.pageSize,
required this.hasNext,
required this.hasPrevious,
});
/// Crée un modèle à partir d'un JSON (API Response)
factory RechercheEvaluationsResponse.fromJson(Map<String, dynamic> json) {
return RechercheEvaluationsResponse(
evaluations: (json['content'] as List<dynamic>)
.map((e) => EvaluationAideModel.fromJson(e as Map<String, dynamic>))
.toList(),
totalElements: json['totalElements'] as int,
totalPages: json['totalPages'] as int,
currentPage: json['number'] as int,
pageSize: json['size'] as int,
hasNext: !(json['last'] as bool),
hasPrevious: !(json['first'] as bool),
);
}
/// Convertit le modèle en JSON
Map<String, dynamic> toJson() {
return {
'content': evaluations.map((e) => e.toJson()).toList(),
'totalElements': totalElements,
'totalPages': totalPages,
'number': currentPage,
'size': pageSize,
'last': !hasNext,
'first': !hasPrevious,
};
}
}
/// Modèle pour les requêtes de création d'évaluation
class CreerEvaluationRequest {
final String demandeId;
final String? propositionId;
final String evaluateurId;
final TypeEvaluateur typeEvaluateur;
final double noteGlobale;
final double? noteDelaiReponse;
final double? noteCommunication;
final double? noteProfessionnalisme;
final double? noteRespectEngagements;
final String commentairePrincipal;
final String? pointsPositifs;
final String? pointsAmelioration;
final String? recommandations;
final bool? recommande;
final bool estPublique;
final Map<String, dynamic> donneesPersonnalisees;
const CreerEvaluationRequest({
required this.demandeId,
this.propositionId,
required this.evaluateurId,
required this.typeEvaluateur,
required this.noteGlobale,
this.noteDelaiReponse,
this.noteCommunication,
this.noteProfessionnalisme,
this.noteRespectEngagements,
required this.commentairePrincipal,
this.pointsPositifs,
this.pointsAmelioration,
this.recommandations,
this.recommande,
this.estPublique = true,
this.donneesPersonnalisees = const {},
});
/// Convertit la requête en JSON
Map<String, dynamic> toJson() {
return {
'demandeId': demandeId,
'propositionId': propositionId,
'evaluateurId': evaluateurId,
'typeEvaluateur': typeEvaluateur.name,
'noteGlobale': noteGlobale,
'noteDelaiReponse': noteDelaiReponse,
'noteCommunication': noteCommunication,
'noteProfessionnalisme': noteProfessionnalisme,
'noteRespectEngagements': noteRespectEngagements,
'commentairePrincipal': commentairePrincipal,
'pointsPositifs': pointsPositifs,
'pointsAmelioration': pointsAmelioration,
'recommandations': recommandations,
'recommande': recommande,
'estPublique': estPublique,
'donneesPersonnalisees': donneesPersonnalisees,
};
}
/// Crée une requête à partir d'une entité d'évaluation
factory CreerEvaluationRequest.fromEntity(EvaluationAide entity) {
return CreerEvaluationRequest(
demandeId: entity.demandeId,
propositionId: entity.propositionId,
evaluateurId: entity.evaluateurId,
typeEvaluateur: entity.typeEvaluateur,
noteGlobale: entity.noteGlobale,
noteDelaiReponse: entity.noteDelaiReponse,
noteCommunication: entity.noteCommunication,
noteProfessionnalisme: entity.noteProfessionnalisme,
noteRespectEngagements: entity.noteRespectEngagements,
commentairePrincipal: entity.commentairePrincipal,
pointsPositifs: entity.pointsPositifs,
pointsAmelioration: entity.pointsAmelioration,
recommandations: entity.recommandations,
recommande: entity.recommande,
estPublique: entity.estPublique,
donneesPersonnalisees: entity.donneesPersonnalisees,
);
}
}

View File

@@ -0,0 +1,335 @@
import '../../domain/entities/proposition_aide.dart';
import '../../domain/entities/demande_aide.dart';
/// Modèle de données pour les propositions d'aide
///
/// Ce modèle fait la conversion entre les DTOs de l'API REST
/// et les entités du domaine pour les propositions d'aide.
class PropositionAideModel extends PropositionAide {
const PropositionAideModel({
required super.id,
required super.titre,
required super.description,
required super.typeAide,
required super.statut,
required super.proposantId,
required super.nomProposant,
required super.organisationId,
required super.nombreMaxBeneficiaires,
super.montantMaximum,
super.montantMinimum,
required super.delaiReponseHeures,
required super.dateCreation,
required super.dateModification,
super.dateExpiration,
super.dateActivation,
super.dateDesactivation,
required super.contactProposant,
super.zonesGeographiques,
super.creneauxDisponibilite,
super.criteresSelection,
super.conditionsSpeciales,
super.nombreBeneficiairesAides,
super.nombreVues,
super.nombreCandidatures,
super.noteMoyenne,
super.nombreEvaluations,
super.donneesPersonnalisees,
super.estVerifiee,
super.estPromue,
});
/// Crée un modèle à partir d'un JSON (API Response)
factory PropositionAideModel.fromJson(Map<String, dynamic> json) {
return PropositionAideModel(
id: json['id'] as String,
titre: json['titre'] as String,
description: json['description'] as String,
typeAide: _parseTypeAide(json['typeAide'] as String),
statut: _parseStatutProposition(json['statut'] as String),
proposantId: json['proposantId'] as String,
nomProposant: json['nomProposant'] as String,
organisationId: json['organisationId'] as String,
nombreMaxBeneficiaires: json['nombreMaxBeneficiaires'] as int,
montantMaximum: json['montantMaximum']?.toDouble(),
montantMinimum: json['montantMinimum']?.toDouble(),
delaiReponseHeures: json['delaiReponseHeures'] as int,
dateCreation: DateTime.parse(json['dateCreation'] as String),
dateModification: DateTime.parse(json['dateModification'] as String),
dateExpiration: json['dateExpiration'] != null
? DateTime.parse(json['dateExpiration'] as String)
: null,
dateActivation: json['dateActivation'] != null
? DateTime.parse(json['dateActivation'] as String)
: null,
dateDesactivation: json['dateDesactivation'] != null
? DateTime.parse(json['dateDesactivation'] as String)
: null,
contactProposant: ContactProposantModel.fromJson(
json['contactProposant'] as Map<String, dynamic>
),
zonesGeographiques: (json['zonesGeographiques'] as List<dynamic>?)
?.cast<String>() ?? [],
creneauxDisponibilite: (json['creneauxDisponibilite'] as List<dynamic>?)
?.map((e) => CreneauDisponibiliteModel.fromJson(e as Map<String, dynamic>))
.toList() ?? [],
criteresSelection: (json['criteresSelection'] as List<dynamic>?)
?.map((e) => CritereSelectionModel.fromJson(e as Map<String, dynamic>))
.toList() ?? [],
conditionsSpeciales: (json['conditionsSpeciales'] as List<dynamic>?)
?.cast<String>() ?? [],
nombreBeneficiairesAides: json['nombreBeneficiairesAides'] as int? ?? 0,
nombreVues: json['nombreVues'] as int? ?? 0,
nombreCandidatures: json['nombreCandidatures'] as int? ?? 0,
noteMoyenne: json['noteMoyenne']?.toDouble(),
nombreEvaluations: json['nombreEvaluations'] as int? ?? 0,
donneesPersonnalisees: Map<String, dynamic>.from(json['donneesPersonnalisees'] ?? {}),
estVerifiee: json['estVerifiee'] as bool? ?? false,
estPromue: json['estPromue'] as bool? ?? false,
);
}
/// Convertit le modèle en JSON (API Request)
Map<String, dynamic> toJson() {
return {
'id': id,
'titre': titre,
'description': description,
'typeAide': typeAide.name,
'statut': statut.name,
'proposantId': proposantId,
'nomProposant': nomProposant,
'organisationId': organisationId,
'nombreMaxBeneficiaires': nombreMaxBeneficiaires,
'montantMaximum': montantMaximum,
'montantMinimum': montantMinimum,
'delaiReponseHeures': delaiReponseHeures,
'dateCreation': dateCreation.toIso8601String(),
'dateModification': dateModification.toIso8601String(),
'dateExpiration': dateExpiration?.toIso8601String(),
'dateActivation': dateActivation?.toIso8601String(),
'dateDesactivation': dateDesactivation?.toIso8601String(),
'contactProposant': (contactProposant as ContactProposantModel).toJson(),
'zonesGeographiques': zonesGeographiques,
'creneauxDisponibilite': creneauxDisponibilite
.map((e) => (e as CreneauDisponibiliteModel).toJson())
.toList(),
'criteresSelection': criteresSelection
.map((e) => (e as CritereSelectionModel).toJson())
.toList(),
'conditionsSpeciales': conditionsSpeciales,
'nombreBeneficiairesAides': nombreBeneficiairesAides,
'nombreVues': nombreVues,
'nombreCandidatures': nombreCandidatures,
'noteMoyenne': noteMoyenne,
'nombreEvaluations': nombreEvaluations,
'donneesPersonnalisees': donneesPersonnalisees,
'estVerifiee': estVerifiee,
'estPromue': estPromue,
};
}
/// Crée un modèle à partir d'une entité du domaine
factory PropositionAideModel.fromEntity(PropositionAide entity) {
return PropositionAideModel(
id: entity.id,
titre: entity.titre,
description: entity.description,
typeAide: entity.typeAide,
statut: entity.statut,
proposantId: entity.proposantId,
nomProposant: entity.nomProposant,
organisationId: entity.organisationId,
nombreMaxBeneficiaires: entity.nombreMaxBeneficiaires,
montantMaximum: entity.montantMaximum,
montantMinimum: entity.montantMinimum,
delaiReponseHeures: entity.delaiReponseHeures,
dateCreation: entity.dateCreation,
dateModification: entity.dateModification,
dateExpiration: entity.dateExpiration,
dateActivation: entity.dateActivation,
dateDesactivation: entity.dateDesactivation,
contactProposant: ContactProposantModel.fromEntity(entity.contactProposant),
zonesGeographiques: List<String>.from(entity.zonesGeographiques),
creneauxDisponibilite: entity.creneauxDisponibilite
.map((e) => CreneauDisponibiliteModel.fromEntity(e))
.toList(),
criteresSelection: entity.criteresSelection
.map((e) => CritereSelectionModel.fromEntity(e))
.toList(),
conditionsSpeciales: List<String>.from(entity.conditionsSpeciales),
nombreBeneficiairesAides: entity.nombreBeneficiairesAides,
nombreVues: entity.nombreVues,
nombreCandidatures: entity.nombreCandidatures,
noteMoyenne: entity.noteMoyenne,
nombreEvaluations: entity.nombreEvaluations,
donneesPersonnalisees: Map<String, dynamic>.from(entity.donneesPersonnalisees),
estVerifiee: entity.estVerifiee,
estPromue: entity.estPromue,
);
}
/// Convertit le modèle en entité du domaine
PropositionAide toEntity() {
return PropositionAide(
id: id,
titre: titre,
description: description,
typeAide: typeAide,
statut: statut,
proposantId: proposantId,
nomProposant: nomProposant,
organisationId: organisationId,
nombreMaxBeneficiaires: nombreMaxBeneficiaires,
montantMaximum: montantMaximum,
montantMinimum: montantMinimum,
delaiReponseHeures: delaiReponseHeures,
dateCreation: dateCreation,
dateModification: dateModification,
dateExpiration: dateExpiration,
dateActivation: dateActivation,
dateDesactivation: dateDesactivation,
contactProposant: contactProposant,
zonesGeographiques: zonesGeographiques,
creneauxDisponibilite: creneauxDisponibilite,
criteresSelection: criteresSelection,
conditionsSpeciales: conditionsSpeciales,
nombreBeneficiairesAides: nombreBeneficiairesAides,
nombreVues: nombreVues,
nombreCandidatures: nombreCandidatures,
noteMoyenne: noteMoyenne,
nombreEvaluations: nombreEvaluations,
donneesPersonnalisees: donneesPersonnalisees,
estVerifiee: estVerifiee,
estPromue: estPromue,
);
}
// Méthodes utilitaires de parsing
static TypeAide _parseTypeAide(String value) {
return TypeAide.values.firstWhere(
(e) => e.name == value,
orElse: () => TypeAide.autre,
);
}
static StatutProposition _parseStatutProposition(String value) {
return StatutProposition.values.firstWhere(
(e) => e.name == value,
orElse: () => StatutProposition.brouillon,
);
}
}
/// Modèles pour les classes auxiliaires
class ContactProposantModel extends ContactProposant {
const ContactProposantModel({
required super.nom,
required super.telephone,
super.email,
super.adresse,
super.heuresDisponibilite,
});
factory ContactProposantModel.fromJson(Map<String, dynamic> json) {
return ContactProposantModel(
nom: json['nom'] as String,
telephone: json['telephone'] as String,
email: json['email'] as String?,
adresse: json['adresse'] as String?,
heuresDisponibilite: json['heuresDisponibilite'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'nom': nom,
'telephone': telephone,
'email': email,
'adresse': adresse,
'heuresDisponibilite': heuresDisponibilite,
};
}
factory ContactProposantModel.fromEntity(ContactProposant entity) {
return ContactProposantModel(
nom: entity.nom,
telephone: entity.telephone,
email: entity.email,
adresse: entity.adresse,
heuresDisponibilite: entity.heuresDisponibilite,
);
}
}
class CreneauDisponibiliteModel extends CreneauDisponibilite {
const CreneauDisponibiliteModel({
required super.jourSemaine,
required super.heureDebut,
required super.heureFin,
super.commentaire,
});
factory CreneauDisponibiliteModel.fromJson(Map<String, dynamic> json) {
return CreneauDisponibiliteModel(
jourSemaine: json['jourSemaine'] as String,
heureDebut: json['heureDebut'] as String,
heureFin: json['heureFin'] as String,
commentaire: json['commentaire'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'jourSemaine': jourSemaine,
'heureDebut': heureDebut,
'heureFin': heureFin,
'commentaire': commentaire,
};
}
factory CreneauDisponibiliteModel.fromEntity(CreneauDisponibilite entity) {
return CreneauDisponibiliteModel(
jourSemaine: entity.jourSemaine,
heureDebut: entity.heureDebut,
heureFin: entity.heureFin,
commentaire: entity.commentaire,
);
}
}
class CritereSelectionModel extends CritereSelection {
const CritereSelectionModel({
required super.nom,
required super.description,
required super.obligatoire,
super.valeurAttendue,
});
factory CritereSelectionModel.fromJson(Map<String, dynamic> json) {
return CritereSelectionModel(
nom: json['nom'] as String,
description: json['description'] as String,
obligatoire: json['obligatoire'] as bool,
valeurAttendue: json['valeurAttendue'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'nom': nom,
'description': description,
'obligatoire': obligatoire,
'valeurAttendue': valeurAttendue,
};
}
factory CritereSelectionModel.fromEntity(CritereSelection entity) {
return CritereSelectionModel(
nom: entity.nom,
description: entity.description,
obligatoire: entity.obligatoire,
valeurAttendue: entity.valeurAttendue,
);
}
}

View File

@@ -0,0 +1,561 @@
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/network/network_info.dart';
import '../../domain/entities/demande_aide.dart';
import '../../domain/entities/proposition_aide.dart';
import '../../domain/entities/evaluation_aide.dart';
import '../../domain/repositories/solidarite_repository.dart';
import '../datasources/solidarite_remote_data_source.dart';
import '../datasources/solidarite_local_data_source.dart';
import '../models/demande_aide_model.dart';
import '../models/proposition_aide_model.dart';
import '../models/evaluation_aide_model.dart';
/// Implémentation du repository de solidarité
///
/// Cette classe implémente le contrat défini dans le domaine
/// en combinant les sources de données locale et distante.
class SolidariteRepositoryImpl implements SolidariteRepository {
final SolidariteRemoteDataSource remoteDataSource;
final SolidariteLocalDataSource localDataSource;
final NetworkInfo networkInfo;
SolidariteRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.networkInfo,
});
// Demandes d'aide
@override
Future<Either<Failure, DemandeAide>> creerDemandeAide(DemandeAide demande) async {
try {
if (await networkInfo.isConnected) {
final demandeModel = DemandeAideModel.fromEntity(demande);
final result = await remoteDataSource.creerDemandeAide(demandeModel);
// Mettre en cache le résultat
await localDataSource.cacherDemandeAide(result);
return Right(result.toEntity());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
// Continuer même si la mise en cache échoue
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, DemandeAide>> mettreAJourDemandeAide(DemandeAide demande) async {
try {
if (await networkInfo.isConnected) {
final demandeModel = DemandeAideModel.fromEntity(demande);
final result = await remoteDataSource.mettreAJourDemandeAide(demandeModel);
// Mettre à jour le cache
await localDataSource.cacherDemandeAide(result);
return Right(result.toEntity());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, DemandeAide>> obtenirDemandeAide(String id) async {
try {
// Essayer d'abord le cache local
final cachedDemande = await localDataSource.obtenirDemandeAideCachee(id);
if (cachedDemande != null && await _estCacheValide()) {
return Right(cachedDemande.toEntity());
}
// Si pas en cache ou cache expiré, aller chercher sur le serveur
if (await networkInfo.isConnected) {
final result = await remoteDataSource.obtenirDemandeAide(id);
// Mettre en cache le résultat
await localDataSource.cacherDemandeAide(result);
return Right(result.toEntity());
} else {
// Si pas de connexion, utiliser le cache même s'il est expiré
if (cachedDemande != null) {
return Right(cachedDemande.toEntity());
}
return Left(NetworkFailure('Aucune connexion internet et aucune donnée en cache'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on NotFoundException catch (e) {
return Left(NotFoundFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, DemandeAide>> soumettreDemande(String demandeId) async {
try {
if (await networkInfo.isConnected) {
final result = await remoteDataSource.soumettreDemande(demandeId);
// Mettre à jour le cache
await localDataSource.cacherDemandeAide(result);
return Right(result.toEntity());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, DemandeAide>> evaluerDemande({
required String demandeId,
required String evaluateurId,
required StatutAide decision,
String? commentaire,
double? montantApprouve,
}) async {
try {
if (await networkInfo.isConnected) {
final result = await remoteDataSource.evaluerDemande(
demandeId: demandeId,
evaluateurId: evaluateurId,
decision: decision.name,
commentaire: commentaire,
montantApprouve: montantApprouve,
);
// Mettre à jour le cache
await localDataSource.cacherDemandeAide(result);
return Right(result.toEntity());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, List<DemandeAide>>> rechercherDemandes({
String? organisationId,
TypeAide? typeAide,
StatutAide? statut,
String? demandeurId,
bool? urgente,
int page = 0,
int taille = 20,
}) async {
try {
if (await networkInfo.isConnected) {
final result = await remoteDataSource.rechercherDemandes(
organisationId: organisationId,
typeAide: typeAide?.name,
statut: statut?.name,
demandeurId: demandeurId,
urgente: urgente,
page: page,
taille: taille,
);
// Mettre en cache les résultats
for (final demande in result) {
await localDataSource.cacherDemandeAide(demande);
}
return Right(result.map((model) => model.toEntity()).toList());
} else {
// Mode hors ligne : rechercher dans le cache local
final cachedDemandes = await localDataSource.obtenirDemandesAideCachees();
var filteredDemandes = cachedDemandes.where((demande) {
if (organisationId != null && demande.organisationId != organisationId) return false;
if (typeAide != null && demande.typeAide != typeAide) return false;
if (statut != null && demande.statut != statut) return false;
if (demandeurId != null && demande.demandeurId != demandeurId) return false;
if (urgente != null && demande.estUrgente != urgente) return false;
return true;
}).toList();
// Pagination locale
final startIndex = page * taille;
final endIndex = (startIndex + taille).clamp(0, filteredDemandes.length);
if (startIndex < filteredDemandes.length) {
filteredDemandes = filteredDemandes.sublist(startIndex, endIndex);
} else {
filteredDemandes = [];
}
return Right(filteredDemandes.map((model) => model.toEntity()).toList());
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, List<DemandeAide>>> obtenirDemandesUrgentes(String organisationId) async {
try {
if (await networkInfo.isConnected) {
final result = await remoteDataSource.obtenirDemandesUrgentes(organisationId);
// Mettre en cache les résultats
for (final demande in result) {
await localDataSource.cacherDemandeAide(demande);
}
return Right(result.map((model) => model.toEntity()).toList());
} else {
// Mode hors ligne : filtrer le cache local
final cachedDemandes = await localDataSource.obtenirDemandesAideCachees();
final demandesUrgentes = cachedDemandes
.where((demande) => demande.organisationId == organisationId && demande.estUrgente)
.toList();
return Right(demandesUrgentes.map((model) => model.toEntity()).toList());
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, List<DemandeAide>>> obtenirMesdemandes(String utilisateurId) async {
try {
if (await networkInfo.isConnected) {
final result = await remoteDataSource.obtenirMesdemandes(utilisateurId);
// Mettre en cache les résultats
for (final demande in result) {
await localDataSource.cacherDemandeAide(demande);
}
return Right(result.map((model) => model.toEntity()).toList());
} else {
// Mode hors ligne : filtrer le cache local
final cachedDemandes = await localDataSource.obtenirDemandesAideCachees();
final mesdemandes = cachedDemandes
.where((demande) => demande.demandeurId == utilisateurId)
.toList();
return Right(mesdemandes.map((model) => model.toEntity()).toList());
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
// Propositions d'aide
@override
Future<Either<Failure, PropositionAide>> creerPropositionAide(PropositionAide proposition) async {
try {
if (await networkInfo.isConnected) {
final propositionModel = PropositionAideModel.fromEntity(proposition);
final result = await remoteDataSource.creerPropositionAide(propositionModel);
// Mettre en cache le résultat
await localDataSource.cacherPropositionAide(result);
return Right(result.toEntity());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, PropositionAide>> mettreAJourPropositionAide(PropositionAide proposition) async {
try {
if (await networkInfo.isConnected) {
final propositionModel = PropositionAideModel.fromEntity(proposition);
final result = await remoteDataSource.mettreAJourPropositionAide(propositionModel);
// Mettre à jour le cache
await localDataSource.cacherPropositionAide(result);
return Right(result.toEntity());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, PropositionAide>> obtenirPropositionAide(String id) async {
try {
// Essayer d'abord le cache local
final cachedProposition = await localDataSource.obtenirPropositionAideCachee(id);
if (cachedProposition != null && await _estCacheValide()) {
return Right(cachedProposition.toEntity());
}
// Si pas en cache ou cache expiré, aller chercher sur le serveur
if (await networkInfo.isConnected) {
final result = await remoteDataSource.obtenirPropositionAide(id);
// Mettre en cache le résultat
await localDataSource.cacherPropositionAide(result);
return Right(result.toEntity());
} else {
// Si pas de connexion, utiliser le cache même s'il est expiré
if (cachedProposition != null) {
return Right(cachedProposition.toEntity());
}
return Left(NetworkFailure('Aucune connexion internet et aucune donnée en cache'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on NotFoundException catch (e) {
return Left(NotFoundFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, PropositionAide>> changerStatutProposition({
required String propositionId,
required bool activer,
}) async {
try {
if (await networkInfo.isConnected) {
final result = await remoteDataSource.changerStatutProposition(
propositionId: propositionId,
activer: activer,
);
// Mettre à jour le cache
await localDataSource.cacherPropositionAide(result);
return Right(result.toEntity());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, List<PropositionAide>>> rechercherPropositions({
String? organisationId,
TypeAide? typeAide,
String? proposantId,
bool? actives,
int page = 0,
int taille = 20,
}) async {
try {
if (await networkInfo.isConnected) {
final result = await remoteDataSource.rechercherPropositions(
organisationId: organisationId,
typeAide: typeAide?.name,
proposantId: proposantId,
actives: actives,
page: page,
taille: taille,
);
// Mettre en cache les résultats
for (final proposition in result) {
await localDataSource.cacherPropositionAide(proposition);
}
return Right(result.map((model) => model.toEntity()).toList());
} else {
// Mode hors ligne : rechercher dans le cache local
final cachedPropositions = await localDataSource.obtenirPropositionsAideCachees();
var filteredPropositions = cachedPropositions.where((proposition) {
if (organisationId != null && proposition.organisationId != organisationId) return false;
if (typeAide != null && proposition.typeAide != typeAide) return false;
if (proposantId != null && proposition.proposantId != proposantId) return false;
if (actives != null && proposition.isActiveEtDisponible != actives) return false;
return true;
}).toList();
// Pagination locale
final startIndex = page * taille;
final endIndex = (startIndex + taille).clamp(0, filteredPropositions.length);
if (startIndex < filteredPropositions.length) {
filteredPropositions = filteredPropositions.sublist(startIndex, endIndex);
} else {
filteredPropositions = [];
}
return Right(filteredPropositions.map((model) => model.toEntity()).toList());
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, List<PropositionAide>>> obtenirPropositionsActives(TypeAide typeAide) async {
try {
if (await networkInfo.isConnected) {
final result = await remoteDataSource.obtenirPropositionsActives(typeAide.name);
// Mettre en cache les résultats
for (final proposition in result) {
await localDataSource.cacherPropositionAide(proposition);
}
return Right(result.map((model) => model.toEntity()).toList());
} else {
// Mode hors ligne : filtrer le cache local
final cachedPropositions = await localDataSource.obtenirPropositionsAideCachees();
final propositionsActives = cachedPropositions
.where((proposition) => proposition.typeAide == typeAide && proposition.isActiveEtDisponible)
.toList();
return Right(propositionsActives.map((model) => model.toEntity()).toList());
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, List<PropositionAide>>> obtenirMeilleuresPropositions(int limite) async {
try {
if (await networkInfo.isConnected) {
final result = await remoteDataSource.obtenirMeilleuresPropositions(limite);
// Mettre en cache les résultats
for (final proposition in result) {
await localDataSource.cacherPropositionAide(proposition);
}
return Right(result.map((model) => model.toEntity()).toList());
} else {
// Mode hors ligne : trier le cache local par note moyenne
final cachedPropositions = await localDataSource.obtenirPropositionsAideCachees();
cachedPropositions.sort((a, b) {
final noteA = a.noteMoyenne ?? 0.0;
final noteB = b.noteMoyenne ?? 0.0;
return noteB.compareTo(noteA);
});
final meilleuresPropositions = cachedPropositions.take(limite).toList();
return Right(meilleuresPropositions.map((model) => model.toEntity()).toList());
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
@override
Future<Either<Failure, List<PropositionAide>>> obtenirMesPropositions(String utilisateurId) async {
try {
if (await networkInfo.isConnected) {
final result = await remoteDataSource.obtenirMesPropositions(utilisateurId);
// Mettre en cache les résultats
for (final proposition in result) {
await localDataSource.cacherPropositionAide(proposition);
}
return Right(result.map((model) => model.toEntity()).toList());
} else {
// Mode hors ligne : filtrer le cache local
final cachedPropositions = await localDataSource.obtenirPropositionsAideCachees();
final mesPropositions = cachedPropositions
.where((proposition) => proposition.proposantId == utilisateurId)
.toList();
return Right(mesPropositions.map((model) => model.toEntity()).toList());
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
// Méthodes utilitaires privées
Future<bool> _estCacheValide() async {
try {
final localDataSourceImpl = localDataSource as SolidariteLocalDataSourceImpl;
return await localDataSourceImpl.estCacheDemandesValide() &&
await localDataSourceImpl.estCachePropositionsValide();
} catch (e) {
return false;
}
}
}

View File

@@ -0,0 +1,338 @@
// Partie 2 de l'implémentation du repository de solidarité
// Cette partie contient les méthodes pour le matching, les évaluations et les statistiques
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/error/exceptions.dart';
import '../../domain/entities/demande_aide.dart';
import '../../domain/entities/proposition_aide.dart';
import '../../domain/entities/evaluation_aide.dart';
import '../datasources/solidarite_remote_data_source.dart';
import '../datasources/solidarite_local_data_source.dart';
import '../models/demande_aide_model.dart';
import '../models/proposition_aide_model.dart';
import '../models/evaluation_aide_model.dart';
/// Extension de l'implémentation du repository de solidarité
/// Cette partie sera intégrée dans la classe principale
mixin SolidariteRepositoryImplPart2 {
SolidariteRemoteDataSource get remoteDataSource;
SolidariteLocalDataSource get localDataSource;
bool Function() get isConnected;
// Matching
Future<Either<Failure, List<PropositionAide>>> trouverPropositionsCompatibles(String demandeId) async {
try {
if (await isConnected()) {
final result = await remoteDataSource.trouverPropositionsCompatibles(demandeId);
// Mettre en cache les résultats
for (final proposition in result) {
await localDataSource.cacherPropositionAide(proposition);
}
return Right(result.map((model) => model.toEntity()).toList());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
Future<Either<Failure, List<DemandeAide>>> trouverDemandesCompatibles(String propositionId) async {
try {
if (await isConnected()) {
final result = await remoteDataSource.trouverDemandesCompatibles(propositionId);
// Mettre en cache les résultats
for (final demande in result) {
await localDataSource.cacherDemandeAide(demande);
}
return Right(result.map((model) => model.toEntity()).toList());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
Future<Either<Failure, List<PropositionAide>>> rechercherProposantsFinanciers(String demandeId) async {
try {
if (await isConnected()) {
final result = await remoteDataSource.rechercherProposantsFinanciers(demandeId);
// Mettre en cache les résultats
for (final proposition in result) {
await localDataSource.cacherPropositionAide(proposition);
}
return Right(result.map((model) => model.toEntity()).toList());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
// Évaluations
Future<Either<Failure, EvaluationAide>> creerEvaluation(EvaluationAide evaluation) async {
try {
if (await isConnected()) {
final evaluationModel = EvaluationAideModel.fromEntity(evaluation);
final result = await remoteDataSource.creerEvaluation(evaluationModel);
// Mettre en cache le résultat
await localDataSource.cacherEvaluation(result);
return Right(result.toEntity());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
Future<Either<Failure, EvaluationAide>> mettreAJourEvaluation(EvaluationAide evaluation) async {
try {
if (await isConnected()) {
final evaluationModel = EvaluationAideModel.fromEntity(evaluation);
final result = await remoteDataSource.mettreAJourEvaluation(evaluationModel);
// Mettre à jour le cache
await localDataSource.cacherEvaluation(result);
return Right(result.toEntity());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
Future<Either<Failure, EvaluationAide>> obtenirEvaluation(String id) async {
try {
// Essayer d'abord le cache local
final cachedEvaluation = await localDataSource.obtenirEvaluationCachee(id);
if (cachedEvaluation != null && await _estCacheEvaluationsValide()) {
return Right(cachedEvaluation.toEntity());
}
// Si pas en cache ou cache expiré, aller chercher sur le serveur
if (await isConnected()) {
final result = await remoteDataSource.obtenirEvaluation(id);
// Mettre en cache le résultat
await localDataSource.cacherEvaluation(result);
return Right(result.toEntity());
} else {
// Si pas de connexion, utiliser le cache même s'il est expiré
if (cachedEvaluation != null) {
return Right(cachedEvaluation.toEntity());
}
return Left(NetworkFailure('Aucune connexion internet et aucune donnée en cache'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on NotFoundException catch (e) {
return Left(NotFoundFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
Future<Either<Failure, List<EvaluationAide>>> obtenirEvaluationsDemande(String demandeId) async {
try {
if (await isConnected()) {
final result = await remoteDataSource.obtenirEvaluationsDemande(demandeId);
// Mettre en cache les résultats
for (final evaluation in result) {
await localDataSource.cacherEvaluation(evaluation);
}
return Right(result.map((model) => model.toEntity()).toList());
} else {
// Mode hors ligne : filtrer le cache local
final cachedEvaluations = await localDataSource.obtenirEvaluationsCachees();
final evaluationsDemande = cachedEvaluations
.where((evaluation) => evaluation.demandeId == demandeId)
.toList();
return Right(evaluationsDemande.map((model) => model.toEntity()).toList());
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
Future<Either<Failure, List<EvaluationAide>>> obtenirEvaluationsProposition(String propositionId) async {
try {
if (await isConnected()) {
final result = await remoteDataSource.obtenirEvaluationsProposition(propositionId);
// Mettre en cache les résultats
for (final evaluation in result) {
await localDataSource.cacherEvaluation(evaluation);
}
return Right(result.map((model) => model.toEntity()).toList());
} else {
// Mode hors ligne : filtrer le cache local
final cachedEvaluations = await localDataSource.obtenirEvaluationsCachees();
final evaluationsProposition = cachedEvaluations
.where((evaluation) => evaluation.propositionId == propositionId)
.toList();
return Right(evaluationsProposition.map((model) => model.toEntity()).toList());
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
Future<Either<Failure, EvaluationAide>> signalerEvaluation({
required String evaluationId,
required String motif,
}) async {
try {
if (await isConnected()) {
final result = await remoteDataSource.signalerEvaluation(
evaluationId: evaluationId,
motif: motif,
);
// Mettre à jour le cache
await localDataSource.cacherEvaluation(result);
return Right(result.toEntity());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
Future<Either<Failure, StatistiquesEvaluation>> calculerMoyenneDemande(String demandeId) async {
try {
if (await isConnected()) {
final result = await remoteDataSource.calculerMoyenneDemande(demandeId);
return Right(result.toEntity());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
Future<Either<Failure, StatistiquesEvaluation>> calculerMoyenneProposition(String propositionId) async {
try {
if (await isConnected()) {
final result = await remoteDataSource.calculerMoyenneProposition(propositionId);
return Right(result.toEntity());
} else {
return Left(NetworkFailure('Aucune connexion internet disponible'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
// Statistiques
Future<Either<Failure, Map<String, dynamic>>> obtenirStatistiquesSolidarite(String organisationId) async {
try {
// Essayer d'abord le cache local
final cachedStats = await localDataSource.obtenirStatistiquesCachees(organisationId);
if (cachedStats != null && await _estCacheStatistiquesValide(organisationId)) {
return Right(cachedStats);
}
// Si pas en cache ou cache expiré, aller chercher sur le serveur
if (await isConnected()) {
final result = await remoteDataSource.obtenirStatistiquesSolidarite(organisationId);
// Mettre en cache le résultat
await localDataSource.cacherStatistiques(organisationId, result);
return Right(result);
} else {
// Si pas de connexion, utiliser le cache même s'il est expiré
if (cachedStats != null) {
return Right(cachedStats);
}
return Left(NetworkFailure('Aucune connexion internet et aucune donnée en cache'));
}
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure(e.toString()));
}
}
// Méthodes utilitaires privées
Future<bool> _estCacheEvaluationsValide() async {
try {
final localDataSourceImpl = localDataSource as SolidariteLocalDataSourceImpl;
return await localDataSourceImpl.estCacheEvaluationsValide();
} catch (e) {
return false;
}
}
Future<bool> _estCacheStatistiquesValide(String organisationId) async {
try {
final localDataSourceImpl = localDataSource as SolidariteLocalDataSourceImpl;
return await localDataSourceImpl.estCacheStatistiquesValide(organisationId);
} catch (e) {
return false;
}
}
}

View File

@@ -0,0 +1,481 @@
import 'package:equatable/equatable.dart';
/// Entité représentant une demande d'aide dans le système de solidarité
///
/// Cette entité encapsule toutes les informations relatives à une demande d'aide,
/// incluant les détails du demandeur, le type d'aide, les montants et le statut.
class DemandeAide extends Equatable {
/// Identifiant unique de la demande
final String id;
/// Numéro de référence unique (format: DA-YYYY-NNNNNN)
final String numeroReference;
/// Titre de la demande d'aide
final String titre;
/// Description détaillée de la demande
final String description;
/// Type d'aide demandée
final TypeAide typeAide;
/// Statut actuel de la demande
final StatutAide statut;
/// Priorité de la demande
final PrioriteAide priorite;
/// Identifiant du demandeur
final String demandeurId;
/// Nom complet du demandeur
final String nomDemandeur;
/// Identifiant de l'organisation
final String organisationId;
/// Montant demandé (si applicable)
final double? montantDemande;
/// Montant approuvé (si applicable)
final double? montantApprouve;
/// Montant versé (si applicable)
final double? montantVerse;
/// Date de création de la demande
final DateTime dateCreation;
/// Date de modification
final DateTime dateModification;
/// Date de soumission
final DateTime? dateSoumission;
/// Date d'évaluation
final DateTime? dateEvaluation;
/// Date d'approbation
final DateTime? dateApprobation;
/// Date limite de traitement
final DateTime? dateLimiteTraitement;
/// Identifiant de l'évaluateur assigné
final String? evaluateurId;
/// Commentaires de l'évaluateur
final String? commentairesEvaluateur;
/// Motif de rejet (si applicable)
final String? motifRejet;
/// Informations complémentaires requises
final String? informationsRequises;
/// Justification de l'urgence
final String? justificationUrgence;
/// Contact d'urgence
final ContactUrgence? contactUrgence;
/// Localisation du demandeur
final Localisation? localisation;
/// Liste des bénéficiaires
final List<BeneficiaireAide> beneficiaires;
/// Liste des pièces justificatives
final List<PieceJustificative> piecesJustificatives;
/// Historique des changements de statut
final List<HistoriqueStatut> historiqueStatuts;
/// Commentaires et échanges
final List<CommentaireAide> commentaires;
/// Données personnalisées
final Map<String, dynamic> donneesPersonnalisees;
/// Indique si la demande est modifiable
final bool estModifiable;
/// Indique si la demande est urgente
final bool estUrgente;
/// Indique si le délai est dépassé
final bool delaiDepasse;
/// Indique si la demande est terminée
final bool estTerminee;
const DemandeAide({
required this.id,
required this.numeroReference,
required this.titre,
required this.description,
required this.typeAide,
required this.statut,
required this.priorite,
required this.demandeurId,
required this.nomDemandeur,
required this.organisationId,
this.montantDemande,
this.montantApprouve,
this.montantVerse,
required this.dateCreation,
required this.dateModification,
this.dateSoumission,
this.dateEvaluation,
this.dateApprobation,
this.dateLimiteTraitement,
this.evaluateurId,
this.commentairesEvaluateur,
this.motifRejet,
this.informationsRequises,
this.justificationUrgence,
this.contactUrgence,
this.localisation,
this.beneficiaires = const [],
this.piecesJustificatives = const [],
this.historiqueStatuts = const [],
this.commentaires = const [],
this.donneesPersonnalisees = const {},
this.estModifiable = false,
this.estUrgente = false,
this.delaiDepasse = false,
this.estTerminee = false,
});
/// Calcule le pourcentage d'avancement de la demande
double get pourcentageAvancement {
return statut.pourcentageAvancement;
}
/// Calcule le délai restant en heures
int? get delaiRestantHeures {
if (dateLimiteTraitement == null) return null;
final maintenant = DateTime.now();
if (maintenant.isAfter(dateLimiteTraitement!)) return 0;
return dateLimiteTraitement!.difference(maintenant).inHours;
}
/// Calcule la durée de traitement en jours
int get dureeTraitementJours {
if (dateSoumission == null) return 0;
final dateFin = dateEvaluation ?? DateTime.now();
return dateFin.difference(dateSoumission!).inDays;
}
/// Indique si la demande nécessite une action urgente
bool get necessiteActionUrgente {
return estUrgente || delaiDepasse || priorite == PrioriteAide.critique;
}
/// Obtient la couleur associée au statut
String get couleurStatut => statut.couleur;
/// Obtient l'icône associée au type d'aide
String get iconeTypeAide => typeAide.icone;
@override
List<Object?> get props => [
id,
numeroReference,
titre,
description,
typeAide,
statut,
priorite,
demandeurId,
nomDemandeur,
organisationId,
montantDemande,
montantApprouve,
montantVerse,
dateCreation,
dateModification,
dateSoumission,
dateEvaluation,
dateApprobation,
dateLimiteTraitement,
evaluateurId,
commentairesEvaluateur,
motifRejet,
informationsRequises,
justificationUrgence,
contactUrgence,
localisation,
beneficiaires,
piecesJustificatives,
historiqueStatuts,
commentaires,
donneesPersonnalisees,
estModifiable,
estUrgente,
delaiDepasse,
estTerminee,
];
DemandeAide copyWith({
String? id,
String? numeroReference,
String? titre,
String? description,
TypeAide? typeAide,
StatutAide? statut,
PrioriteAide? priorite,
String? demandeurId,
String? nomDemandeur,
String? organisationId,
double? montantDemande,
double? montantApprouve,
double? montantVerse,
DateTime? dateCreation,
DateTime? dateModification,
DateTime? dateSoumission,
DateTime? dateEvaluation,
DateTime? dateApprobation,
DateTime? dateLimiteTraitement,
String? evaluateurId,
String? commentairesEvaluateur,
String? motifRejet,
String? informationsRequises,
String? justificationUrgence,
ContactUrgence? contactUrgence,
Localisation? localisation,
List<BeneficiaireAide>? beneficiaires,
List<PieceJustificative>? piecesJustificatives,
List<HistoriqueStatut>? historiqueStatuts,
List<CommentaireAide>? commentaires,
Map<String, dynamic>? donneesPersonnalisees,
bool? estModifiable,
bool? estUrgente,
bool? delaiDepasse,
bool? estTerminee,
}) {
return DemandeAide(
id: id ?? this.id,
numeroReference: numeroReference ?? this.numeroReference,
titre: titre ?? this.titre,
description: description ?? this.description,
typeAide: typeAide ?? this.typeAide,
statut: statut ?? this.statut,
priorite: priorite ?? this.priorite,
demandeurId: demandeurId ?? this.demandeurId,
nomDemandeur: nomDemandeur ?? this.nomDemandeur,
organisationId: organisationId ?? this.organisationId,
montantDemande: montantDemande ?? this.montantDemande,
montantApprouve: montantApprouve ?? this.montantApprouve,
montantVerse: montantVerse ?? this.montantVerse,
dateCreation: dateCreation ?? this.dateCreation,
dateModification: dateModification ?? this.dateModification,
dateSoumission: dateSoumission ?? this.dateSoumission,
dateEvaluation: dateEvaluation ?? this.dateEvaluation,
dateApprobation: dateApprobation ?? this.dateApprobation,
dateLimiteTraitement: dateLimiteTraitement ?? this.dateLimiteTraitement,
evaluateurId: evaluateurId ?? this.evaluateurId,
commentairesEvaluateur: commentairesEvaluateur ?? this.commentairesEvaluateur,
motifRejet: motifRejet ?? this.motifRejet,
informationsRequises: informationsRequises ?? this.informationsRequises,
justificationUrgence: justificationUrgence ?? this.justificationUrgence,
contactUrgence: contactUrgence ?? this.contactUrgence,
localisation: localisation ?? this.localisation,
beneficiaires: beneficiaires ?? this.beneficiaires,
piecesJustificatives: piecesJustificatives ?? this.piecesJustificatives,
historiqueStatuts: historiqueStatuts ?? this.historiqueStatuts,
commentaires: commentaires ?? this.commentaires,
donneesPersonnalisees: donneesPersonnalisees ?? this.donneesPersonnalisees,
estModifiable: estModifiable ?? this.estModifiable,
estUrgente: estUrgente ?? this.estUrgente,
delaiDepasse: delaiDepasse ?? this.delaiDepasse,
estTerminee: estTerminee ?? this.estTerminee,
);
}
}
/// Énumération des types d'aide disponibles
enum TypeAide {
aideFinanciereUrgente('Aide financière urgente', 'emergency_fund', '#F44336'),
aideFinanciereMedicale('Aide financière médicale', 'medical_services', '#2196F3'),
aideFinanciereEducation('Aide financière éducation', 'school', '#4CAF50'),
aideMaterielleVetements('Aide matérielle vêtements', 'checkroom', '#FF9800'),
aideMaterielleNourriture('Aide matérielle nourriture', 'restaurant', '#795548'),
aideProfessionnelleFormation('Aide professionnelle formation', 'work', '#9C27B0'),
aideSocialeAccompagnement('Aide sociale accompagnement', 'support', '#607D8B'),
autre('Autre', 'help', '#9E9E9E');
const TypeAide(this.libelle, this.icone, this.couleur);
final String libelle;
final String icone;
final String couleur;
}
/// Énumération des statuts de demande d'aide
enum StatutAide {
brouillon('Brouillon', 'draft', '#9E9E9E', 5.0),
soumise('Soumise', 'send', '#2196F3', 10.0),
enAttente('En attente', 'schedule', '#FF9800', 20.0),
enCoursEvaluation('En cours d\'évaluation', 'assessment', '#9C27B0', 40.0),
approuvee('Approuvée', 'check_circle', '#4CAF50', 70.0),
approuveePartiellement('Approuvée partiellement', 'check_circle_outline', '#8BC34A', 70.0),
rejetee('Rejetée', 'cancel', '#F44336', 100.0),
informationsRequises('Informations requises', 'info', '#FF5722', 30.0),
enCoursVersement('En cours de versement', 'payment', '#00BCD4', 85.0),
versee('Versée', 'paid', '#4CAF50', 100.0),
livree('Livrée', 'local_shipping', '#4CAF50', 100.0),
terminee('Terminée', 'done_all', '#4CAF50', 100.0),
cloturee('Clôturée', 'archive', '#607D8B', 100.0);
const StatutAide(this.libelle, this.icone, this.couleur, this.pourcentageAvancement);
final String libelle;
final String icone;
final String couleur;
final double pourcentageAvancement;
}
/// Énumération des priorités de demande d'aide
enum PrioriteAide {
critique('Critique', '#F44336', 1, 24),
urgente('Urgente', '#FF5722', 2, 72),
elevee('Élevée', '#FF9800', 3, 168),
normale('Normale', '#4CAF50', 4, 336),
faible('Faible', '#9E9E9E', 5, 720);
const PrioriteAide(this.libelle, this.couleur, this.niveau, this.delaiTraitementHeures);
final String libelle;
final String couleur;
final int niveau;
final int delaiTraitementHeures;
}
/// Classe représentant un contact d'urgence
class ContactUrgence extends Equatable {
final String nom;
final String telephone;
final String? email;
final String relation;
const ContactUrgence({
required this.nom,
required this.telephone,
this.email,
required this.relation,
});
@override
List<Object?> get props => [nom, telephone, email, relation];
}
/// Classe représentant une localisation
class Localisation extends Equatable {
final String adresse;
final String ville;
final String? codePostal;
final String? pays;
final double? latitude;
final double? longitude;
const Localisation({
required this.adresse,
required this.ville,
this.codePostal,
this.pays,
this.latitude,
this.longitude,
});
@override
List<Object?> get props => [adresse, ville, codePostal, pays, latitude, longitude];
}
/// Classe représentant un bénéficiaire d'aide
class BeneficiaireAide extends Equatable {
final String nom;
final String prenom;
final int age;
final String relation;
final String? telephone;
const BeneficiaireAide({
required this.nom,
required this.prenom,
required this.age,
required this.relation,
this.telephone,
});
@override
List<Object?> get props => [nom, prenom, age, relation, telephone];
}
/// Classe représentant une pièce justificative
class PieceJustificative extends Equatable {
final String id;
final String nom;
final String type;
final String url;
final int taille;
final DateTime dateAjout;
const PieceJustificative({
required this.id,
required this.nom,
required this.type,
required this.url,
required this.taille,
required this.dateAjout,
});
@override
List<Object?> get props => [id, nom, type, url, taille, dateAjout];
}
/// Classe représentant l'historique des statuts
class HistoriqueStatut extends Equatable {
final StatutAide ancienStatut;
final StatutAide nouveauStatut;
final DateTime dateChangement;
final String? commentaire;
final String? utilisateurId;
const HistoriqueStatut({
required this.ancienStatut,
required this.nouveauStatut,
required this.dateChangement,
this.commentaire,
this.utilisateurId,
});
@override
List<Object?> get props => [ancienStatut, nouveauStatut, dateChangement, commentaire, utilisateurId];
}
/// Classe représentant un commentaire sur une demande
class CommentaireAide extends Equatable {
final String id;
final String contenu;
final String auteurId;
final String nomAuteur;
final DateTime dateCreation;
final bool estPrive;
const CommentaireAide({
required this.id,
required this.contenu,
required this.auteurId,
required this.nomAuteur,
required this.dateCreation,
this.estPrive = false,
});
@override
List<Object?> get props => [id, contenu, auteurId, nomAuteur, dateCreation, estPrive];
}

View File

@@ -0,0 +1,303 @@
import 'package:equatable/equatable.dart';
/// Entité représentant une évaluation d'aide dans le système de solidarité
///
/// Cette entité encapsule toutes les informations relatives à l'évaluation
/// d'une demande d'aide ou d'une proposition d'aide.
class EvaluationAide extends Equatable {
/// Identifiant unique de l'évaluation
final String id;
/// Identifiant de la demande d'aide évaluée
final String demandeAideId;
/// Identifiant de la proposition d'aide (si applicable)
final String? propositionAideId;
/// Identifiant de l'évaluateur
final String evaluateurId;
/// Nom de l'évaluateur
final String nomEvaluateur;
/// Type d'évaluateur
final TypeEvaluateur typeEvaluateur;
/// Note globale (1 à 5)
final double noteGlobale;
/// Note pour le délai de réponse
final double? noteDelaiReponse;
/// Note pour la communication
final double? noteCommunication;
/// Note pour le professionnalisme
final double? noteProfessionnalisme;
/// Note pour le respect des engagements
final double? noteRespectEngagements;
/// Notes détaillées par critère
final Map<String, double> notesDetaillees;
/// Commentaire principal
final String commentairePrincipal;
/// Points positifs
final String? pointsPositifs;
/// Points d'amélioration
final String? pointsAmelioration;
/// Recommandations
final String? recommandations;
/// Indique si l'évaluateur recommande cette aide
final bool? recommande;
/// Date de création de l'évaluation
final DateTime dateCreation;
/// Date de modification
final DateTime dateModification;
/// Date de vérification (si applicable)
final DateTime? dateVerification;
/// Identifiant du vérificateur
final String? verificateurId;
/// Statut de l'évaluation
final StatutEvaluation statut;
/// Nombre de signalements reçus
final int nombreSignalements;
/// Score de qualité calculé automatiquement
final double scoreQualite;
/// Indique si l'évaluation a été modifiée
final bool estModifie;
/// Indique si l'évaluation est vérifiée
final bool estVerifiee;
/// Données personnalisées
final Map<String, dynamic> donneesPersonnalisees;
const EvaluationAide({
required this.id,
required this.demandeAideId,
this.propositionAideId,
required this.evaluateurId,
required this.nomEvaluateur,
required this.typeEvaluateur,
required this.noteGlobale,
this.noteDelaiReponse,
this.noteCommunication,
this.noteProfessionnalisme,
this.noteRespectEngagements,
this.notesDetaillees = const {},
required this.commentairePrincipal,
this.pointsPositifs,
this.pointsAmelioration,
this.recommandations,
this.recommande,
required this.dateCreation,
required this.dateModification,
this.dateVerification,
this.verificateurId,
this.statut = StatutEvaluation.active,
this.nombreSignalements = 0,
required this.scoreQualite,
this.estModifie = false,
this.estVerifiee = false,
this.donneesPersonnalisees = const {},
});
/// Calcule la note moyenne des critères détaillés
double get noteMoyenneDetaillees {
if (notesDetaillees.isEmpty) return noteGlobale;
double somme = notesDetaillees.values.fold(0.0, (a, b) => a + b);
return somme / notesDetaillees.length;
}
/// Indique si l'évaluation est positive (note >= 4)
bool get estPositive => noteGlobale >= 4.0;
/// Indique si l'évaluation est négative (note <= 2)
bool get estNegative => noteGlobale <= 2.0;
/// Obtient le niveau de satisfaction textuel
String get niveauSatisfaction {
if (noteGlobale >= 4.5) return 'Excellent';
if (noteGlobale >= 4.0) return 'Très bien';
if (noteGlobale >= 3.0) return 'Bien';
if (noteGlobale >= 2.0) return 'Moyen';
return 'Insuffisant';
}
/// Obtient la couleur associée à la note
String get couleurNote {
if (noteGlobale >= 4.0) return '#4CAF50'; // Vert
if (noteGlobale >= 3.0) return '#FF9800'; // Orange
return '#F44336'; // Rouge
}
/// Indique si l'évaluation peut être modifiée
bool get peutEtreModifiee {
return statut == StatutEvaluation.active &&
!estVerifiee &&
nombreSignalements < 3;
}
@override
List<Object?> get props => [
id,
demandeAideId,
propositionAideId,
evaluateurId,
nomEvaluateur,
typeEvaluateur,
noteGlobale,
noteDelaiReponse,
noteCommunication,
noteProfessionnalisme,
noteRespectEngagements,
notesDetaillees,
commentairePrincipal,
pointsPositifs,
pointsAmelioration,
recommandations,
recommande,
dateCreation,
dateModification,
dateVerification,
verificateurId,
statut,
nombreSignalements,
scoreQualite,
estModifie,
estVerifiee,
donneesPersonnalisees,
];
EvaluationAide copyWith({
String? id,
String? demandeAideId,
String? propositionAideId,
String? evaluateurId,
String? nomEvaluateur,
TypeEvaluateur? typeEvaluateur,
double? noteGlobale,
double? noteDelaiReponse,
double? noteCommunication,
double? noteProfessionnalisme,
double? noteRespectEngagements,
Map<String, double>? notesDetaillees,
String? commentairePrincipal,
String? pointsPositifs,
String? pointsAmelioration,
String? recommandations,
bool? recommande,
DateTime? dateCreation,
DateTime? dateModification,
DateTime? dateVerification,
String? verificateurId,
StatutEvaluation? statut,
int? nombreSignalements,
double? scoreQualite,
bool? estModifie,
bool? estVerifiee,
Map<String, dynamic>? donneesPersonnalisees,
}) {
return EvaluationAide(
id: id ?? this.id,
demandeAideId: demandeAideId ?? this.demandeAideId,
propositionAideId: propositionAideId ?? this.propositionAideId,
evaluateurId: evaluateurId ?? this.evaluateurId,
nomEvaluateur: nomEvaluateur ?? this.nomEvaluateur,
typeEvaluateur: typeEvaluateur ?? this.typeEvaluateur,
noteGlobale: noteGlobale ?? this.noteGlobale,
noteDelaiReponse: noteDelaiReponse ?? this.noteDelaiReponse,
noteCommunication: noteCommunication ?? this.noteCommunication,
noteProfessionnalisme: noteProfessionnalisme ?? this.noteProfessionnalisme,
noteRespectEngagements: noteRespectEngagements ?? this.noteRespectEngagements,
notesDetaillees: notesDetaillees ?? this.notesDetaillees,
commentairePrincipal: commentairePrincipal ?? this.commentairePrincipal,
pointsPositifs: pointsPositifs ?? this.pointsPositifs,
pointsAmelioration: pointsAmelioration ?? this.pointsAmelioration,
recommandations: recommandations ?? this.recommandations,
recommande: recommande ?? this.recommande,
dateCreation: dateCreation ?? this.dateCreation,
dateModification: dateModification ?? this.dateModification,
dateVerification: dateVerification ?? this.dateVerification,
verificateurId: verificateurId ?? this.verificateurId,
statut: statut ?? this.statut,
nombreSignalements: nombreSignalements ?? this.nombreSignalements,
scoreQualite: scoreQualite ?? this.scoreQualite,
estModifie: estModifie ?? this.estModifie,
estVerifiee: estVerifiee ?? this.estVerifiee,
donneesPersonnalisees: donneesPersonnalisees ?? this.donneesPersonnalisees,
);
}
}
/// Énumération des types d'évaluateur
enum TypeEvaluateur {
beneficiaire('Bénéficiaire', 'person', '#2196F3'),
proposant('Proposant', 'volunteer_activism', '#4CAF50'),
evaluateurOfficial('Évaluateur officiel', 'verified_user', '#9C27B0'),
administrateur('Administrateur', 'admin_panel_settings', '#FF5722');
const TypeEvaluateur(this.libelle, this.icone, this.couleur);
final String libelle;
final String icone;
final String couleur;
}
/// Énumération des statuts d'évaluation
enum StatutEvaluation {
active('Active', 'check_circle', '#4CAF50'),
signalee('Signalée', 'flag', '#FF9800'),
masquee('Masquée', 'visibility_off', '#F44336'),
supprimee('Supprimée', 'delete', '#9E9E9E');
const StatutEvaluation(this.libelle, this.icone, this.couleur);
final String libelle;
final String icone;
final String couleur;
}
/// Classe représentant les statistiques d'évaluations
class StatistiquesEvaluation extends Equatable {
final double noteMoyenne;
final int nombreEvaluations;
final Map<int, int> repartitionNotes;
final double pourcentagePositives;
final double pourcentageRecommandations;
final DateTime derniereMiseAJour;
const StatistiquesEvaluation({
required this.noteMoyenne,
required this.nombreEvaluations,
required this.repartitionNotes,
required this.pourcentagePositives,
required this.pourcentageRecommandations,
required this.derniereMiseAJour,
});
@override
List<Object?> get props => [
noteMoyenne,
nombreEvaluations,
repartitionNotes,
pourcentagePositives,
pourcentageRecommandations,
derniereMiseAJour,
];
}

View File

@@ -0,0 +1,401 @@
import 'package:equatable/equatable.dart';
import 'demande_aide.dart';
/// Entité représentant une proposition d'aide dans le système de solidarité
///
/// Cette entité encapsule toutes les informations relatives à une proposition d'aide,
/// incluant les détails du proposant, les capacités et les conditions.
class PropositionAide extends Equatable {
/// Identifiant unique de la proposition
final String id;
/// Numéro de référence unique (format: PA-YYYY-NNNNNN)
final String numeroReference;
/// Titre de la proposition d'aide
final String titre;
/// Description détaillée de la proposition
final String description;
/// Type d'aide proposée
final TypeAide typeAide;
/// Statut actuel de la proposition
final StatutProposition statut;
/// Identifiant du proposant
final String proposantId;
/// Nom complet du proposant
final String nomProposant;
/// Identifiant de l'organisation
final String organisationId;
/// Montant maximum proposé (si applicable)
final double? montantMaximum;
/// Montant minimum proposé (si applicable)
final double? montantMinimum;
/// Nombre maximum de bénéficiaires
final int nombreMaxBeneficiaires;
/// Nombre de bénéficiaires déjà aidés
final int nombreBeneficiairesAides;
/// Nombre de demandes traitées
final int nombreDemandesTraitees;
/// Montant total versé
final double montantTotalVerse;
/// Date de création de la proposition
final DateTime dateCreation;
/// Date de modification
final DateTime dateModification;
/// Date d'expiration
final DateTime? dateExpiration;
/// Délai de réponse en heures
final int delaiReponseHeures;
/// Zones géographiques couvertes
final List<String> zonesGeographiques;
/// Créneaux de disponibilité
final List<CreneauDisponibilite> creneauxDisponibilite;
/// Critères de sélection
final List<CritereSelection> criteresSelection;
/// Contact du proposant
final ContactProposant contactProposant;
/// Conditions particulières
final String? conditionsParticulieres;
/// Instructions spéciales
final String? instructionsSpeciales;
/// Note moyenne des évaluations
final double? noteMoyenne;
/// Nombre d'évaluations reçues
final int nombreEvaluations;
/// Nombre de vues de la proposition
final int nombreVues;
/// Nombre de candidatures reçues
final int nombreCandidatures;
/// Score de pertinence calculé
final double scorePertinence;
/// Données personnalisées
final Map<String, dynamic> donneesPersonnalisees;
/// Indique si la proposition est disponible
final bool estDisponible;
/// Indique si la proposition est vérifiée
final bool estVerifiee;
/// Indique si la proposition est expirée
final bool estExpiree;
const PropositionAide({
required this.id,
required this.numeroReference,
required this.titre,
required this.description,
required this.typeAide,
required this.statut,
required this.proposantId,
required this.nomProposant,
required this.organisationId,
this.montantMaximum,
this.montantMinimum,
required this.nombreMaxBeneficiaires,
this.nombreBeneficiairesAides = 0,
this.nombreDemandesTraitees = 0,
this.montantTotalVerse = 0.0,
required this.dateCreation,
required this.dateModification,
this.dateExpiration,
this.delaiReponseHeures = 48,
this.zonesGeographiques = const [],
this.creneauxDisponibilite = const [],
this.criteresSelection = const [],
required this.contactProposant,
this.conditionsParticulieres,
this.instructionsSpeciales,
this.noteMoyenne,
this.nombreEvaluations = 0,
this.nombreVues = 0,
this.nombreCandidatures = 0,
this.scorePertinence = 50.0,
this.donneesPersonnalisees = const {},
this.estDisponible = true,
this.estVerifiee = false,
this.estExpiree = false,
});
/// Calcule le nombre de places restantes
int get placesRestantes {
return nombreMaxBeneficiaires - nombreBeneficiairesAides;
}
/// Calcule le pourcentage de capacité utilisée
double get pourcentageCapaciteUtilisee {
if (nombreMaxBeneficiaires == 0) return 0.0;
return (nombreBeneficiairesAides / nombreMaxBeneficiaires) * 100;
}
/// Indique si la proposition peut accepter de nouveaux bénéficiaires
bool get peutAccepterBeneficiaires {
return estDisponible && !estExpiree && placesRestantes > 0;
}
/// Indique si la proposition est active et disponible
bool get isActiveEtDisponible {
return statut == StatutProposition.active && estDisponible && !estExpiree;
}
/// Calcule un score de compatibilité avec une demande
double calculerScoreCompatibilite(DemandeAide demande) {
double score = 0.0;
// Correspondance du type d'aide (40 points max)
if (demande.typeAide == typeAide) {
score += 40.0;
} else {
// Bonus partiel pour les types similaires
score += 20.0;
}
// Compatibilité financière (25 points max)
if (demande.montantDemande != null && montantMaximum != null) {
if (demande.montantDemande! <= montantMaximum!) {
score += 25.0;
} else {
// Pénalité proportionnelle
double ratio = montantMaximum! / demande.montantDemande!;
score += 25.0 * ratio;
}
} else if (demande.montantDemande == null) {
score += 25.0; // Pas de contrainte financière
}
// Expérience du proposant (15 points max)
if (nombreBeneficiairesAides > 0) {
score += (nombreBeneficiairesAides * 2.0).clamp(0.0, 15.0);
}
// Réputation (10 points max)
if (noteMoyenne != null && nombreEvaluations >= 3) {
score += (noteMoyenne! - 3.0) * 3.33;
}
// Disponibilité (10 points max)
if (peutAccepterBeneficiaires) {
double ratioCapacite = placesRestantes / nombreMaxBeneficiaires;
score += 10.0 * ratioCapacite;
}
return score.clamp(0.0, 100.0);
}
/// Obtient la couleur associée au statut
String get couleurStatut => statut.couleur;
/// Obtient l'icône associée au type d'aide
String get iconeTypeAide => typeAide.icone;
@override
List<Object?> get props => [
id,
numeroReference,
titre,
description,
typeAide,
statut,
proposantId,
nomProposant,
organisationId,
montantMaximum,
montantMinimum,
nombreMaxBeneficiaires,
nombreBeneficiairesAides,
nombreDemandesTraitees,
montantTotalVerse,
dateCreation,
dateModification,
dateExpiration,
delaiReponseHeures,
zonesGeographiques,
creneauxDisponibilite,
criteresSelection,
contactProposant,
conditionsParticulieres,
instructionsSpeciales,
noteMoyenne,
nombreEvaluations,
nombreVues,
nombreCandidatures,
scorePertinence,
donneesPersonnalisees,
estDisponible,
estVerifiee,
estExpiree,
];
PropositionAide copyWith({
String? id,
String? numeroReference,
String? titre,
String? description,
TypeAide? typeAide,
StatutProposition? statut,
String? proposantId,
String? nomProposant,
String? organisationId,
double? montantMaximum,
double? montantMinimum,
int? nombreMaxBeneficiaires,
int? nombreBeneficiairesAides,
int? nombreDemandesTraitees,
double? montantTotalVerse,
DateTime? dateCreation,
DateTime? dateModification,
DateTime? dateExpiration,
int? delaiReponseHeures,
List<String>? zonesGeographiques,
List<CreneauDisponibilite>? creneauxDisponibilite,
List<CritereSelection>? criteresSelection,
ContactProposant? contactProposant,
String? conditionsParticulieres,
String? instructionsSpeciales,
double? noteMoyenne,
int? nombreEvaluations,
int? nombreVues,
int? nombreCandidatures,
double? scorePertinence,
Map<String, dynamic>? donneesPersonnalisees,
bool? estDisponible,
bool? estVerifiee,
bool? estExpiree,
}) {
return PropositionAide(
id: id ?? this.id,
numeroReference: numeroReference ?? this.numeroReference,
titre: titre ?? this.titre,
description: description ?? this.description,
typeAide: typeAide ?? this.typeAide,
statut: statut ?? this.statut,
proposantId: proposantId ?? this.proposantId,
nomProposant: nomProposant ?? this.nomProposant,
organisationId: organisationId ?? this.organisationId,
montantMaximum: montantMaximum ?? this.montantMaximum,
montantMinimum: montantMinimum ?? this.montantMinimum,
nombreMaxBeneficiaires: nombreMaxBeneficiaires ?? this.nombreMaxBeneficiaires,
nombreBeneficiairesAides: nombreBeneficiairesAides ?? this.nombreBeneficiairesAides,
nombreDemandesTraitees: nombreDemandesTraitees ?? this.nombreDemandesTraitees,
montantTotalVerse: montantTotalVerse ?? this.montantTotalVerse,
dateCreation: dateCreation ?? this.dateCreation,
dateModification: dateModification ?? this.dateModification,
dateExpiration: dateExpiration ?? this.dateExpiration,
delaiReponseHeures: delaiReponseHeures ?? this.delaiReponseHeures,
zonesGeographiques: zonesGeographiques ?? this.zonesGeographiques,
creneauxDisponibilite: creneauxDisponibilite ?? this.creneauxDisponibilite,
criteresSelection: criteresSelection ?? this.criteresSelection,
contactProposant: contactProposant ?? this.contactProposant,
conditionsParticulieres: conditionsParticulieres ?? this.conditionsParticulieres,
instructionsSpeciales: instructionsSpeciales ?? this.instructionsSpeciales,
noteMoyenne: noteMoyenne ?? this.noteMoyenne,
nombreEvaluations: nombreEvaluations ?? this.nombreEvaluations,
nombreVues: nombreVues ?? this.nombreVues,
nombreCandidatures: nombreCandidatures ?? this.nombreCandidatures,
scorePertinence: scorePertinence ?? this.scorePertinence,
donneesPersonnalisees: donneesPersonnalisees ?? this.donneesPersonnalisees,
estDisponible: estDisponible ?? this.estDisponible,
estVerifiee: estVerifiee ?? this.estVerifiee,
estExpiree: estExpiree ?? this.estExpiree,
);
}
}
/// Énumération des statuts de proposition d'aide
enum StatutProposition {
active('Active', 'check_circle', '#4CAF50'),
suspendue('Suspendue', 'pause_circle', '#FF9800'),
terminee('Terminée', 'done_all', '#607D8B'),
expiree('Expirée', 'schedule', '#9E9E9E'),
supprimee('Supprimée', 'delete', '#F44336');
const StatutProposition(this.libelle, this.icone, this.couleur);
final String libelle;
final String icone;
final String couleur;
}
/// Classe représentant un créneau de disponibilité
class CreneauDisponibilite extends Equatable {
final String jourSemaine;
final String heureDebut;
final String heureFin;
final String? commentaire;
const CreneauDisponibilite({
required this.jourSemaine,
required this.heureDebut,
required this.heureFin,
this.commentaire,
});
@override
List<Object?> get props => [jourSemaine, heureDebut, heureFin, commentaire];
}
/// Classe représentant un critère de sélection
class CritereSelection extends Equatable {
final String nom;
final String valeur;
final bool estObligatoire;
final String? description;
const CritereSelection({
required this.nom,
required this.valeur,
this.estObligatoire = false,
this.description,
});
@override
List<Object?> get props => [nom, valeur, estObligatoire, description];
}
/// Classe représentant le contact d'un proposant
class ContactProposant extends Equatable {
final String nom;
final String telephone;
final String? email;
final String? adresse;
final String? methodePrefereee;
const ContactProposant({
required this.nom,
required this.telephone,
this.email,
this.adresse,
this.methodePrefereee,
});
@override
List<Object?> get props => [nom, telephone, email, adresse, methodePrefereee];
}

View File

@@ -0,0 +1,251 @@
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/demande_aide.dart';
import '../entities/proposition_aide.dart';
import '../entities/evaluation_aide.dart';
/// Repository abstrait pour la gestion de la solidarité
///
/// Ce repository définit les contrats pour toutes les opérations
/// liées au système de solidarité : demandes, propositions, évaluations.
abstract class SolidariteRepository {
// === GESTION DES DEMANDES D'AIDE ===
/// Crée une nouvelle demande d'aide
///
/// [demande] La demande d'aide à créer
/// Retourne [Right(DemandeAide)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, DemandeAide>> creerDemandeAide(DemandeAide demande);
/// Met à jour une demande d'aide existante
///
/// [demande] La demande d'aide à mettre à jour
/// Retourne [Right(DemandeAide)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, DemandeAide>> mettreAJourDemandeAide(DemandeAide demande);
/// Obtient une demande d'aide par son ID
///
/// [id] Identifiant de la demande
/// Retourne [Right(DemandeAide)] si trouvée
/// Retourne [Left(Failure)] si non trouvée ou erreur
Future<Either<Failure, DemandeAide>> obtenirDemandeAide(String id);
/// Soumet une demande d'aide pour évaluation
///
/// [demandeId] Identifiant de la demande
/// Retourne [Right(DemandeAide)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, DemandeAide>> soumettreDemande(String demandeId);
/// Évalue une demande d'aide
///
/// [demandeId] Identifiant de la demande
/// [evaluateurId] Identifiant de l'évaluateur
/// [decision] Décision d'évaluation
/// [commentaire] Commentaire de l'évaluateur
/// [montantApprouve] Montant approuvé (optionnel)
/// Retourne [Right(DemandeAide)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, DemandeAide>> evaluerDemande({
required String demandeId,
required String evaluateurId,
required StatutAide decision,
String? commentaire,
double? montantApprouve,
});
/// Recherche des demandes d'aide avec filtres
///
/// [filtres] Critères de recherche
/// Retourne [Right(List<DemandeAide>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, List<DemandeAide>>> rechercherDemandes({
String? organisationId,
TypeAide? typeAide,
StatutAide? statut,
String? demandeurId,
bool? urgente,
int page = 0,
int taille = 20,
});
/// Obtient les demandes urgentes
///
/// [organisationId] Identifiant de l'organisation
/// Retourne [Right(List<DemandeAide>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, List<DemandeAide>>> obtenirDemandesUrgentes(String organisationId);
/// Obtient les demandes de l'utilisateur connecté
///
/// [utilisateurId] Identifiant de l'utilisateur
/// Retourne [Right(List<DemandeAide>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, List<DemandeAide>>> obtenirMesdemandes(String utilisateurId);
// === GESTION DES PROPOSITIONS D'AIDE ===
/// Crée une nouvelle proposition d'aide
///
/// [proposition] La proposition d'aide à créer
/// Retourne [Right(PropositionAide)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, PropositionAide>> creerPropositionAide(PropositionAide proposition);
/// Met à jour une proposition d'aide existante
///
/// [proposition] La proposition d'aide à mettre à jour
/// Retourne [Right(PropositionAide)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, PropositionAide>> mettreAJourPropositionAide(PropositionAide proposition);
/// Obtient une proposition d'aide par son ID
///
/// [id] Identifiant de la proposition
/// Retourne [Right(PropositionAide)] si trouvée
/// Retourne [Left(Failure)] si non trouvée ou erreur
Future<Either<Failure, PropositionAide>> obtenirPropositionAide(String id);
/// Active ou désactive une proposition d'aide
///
/// [propositionId] Identifiant de la proposition
/// [activer] true pour activer, false pour désactiver
/// Retourne [Right(PropositionAide)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, PropositionAide>> changerStatutProposition({
required String propositionId,
required bool activer,
});
/// Recherche des propositions d'aide avec filtres
///
/// [filtres] Critères de recherche
/// Retourne [Right(List<PropositionAide>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, List<PropositionAide>>> rechercherPropositions({
String? organisationId,
TypeAide? typeAide,
String? proposantId,
bool? actives,
int page = 0,
int taille = 20,
});
/// Obtient les propositions actives pour un type d'aide
///
/// [typeAide] Type d'aide recherché
/// Retourne [Right(List<PropositionAide>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, List<PropositionAide>>> obtenirPropositionsActives(TypeAide typeAide);
/// Obtient les meilleures propositions (top performers)
///
/// [limite] Nombre maximum de propositions à retourner
/// Retourne [Right(List<PropositionAide>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, List<PropositionAide>>> obtenirMeilleuresPropositions(int limite);
/// Obtient les propositions de l'utilisateur connecté
///
/// [utilisateurId] Identifiant de l'utilisateur
/// Retourne [Right(List<PropositionAide>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, List<PropositionAide>>> obtenirMesPropositions(String utilisateurId);
// === MATCHING ET COMPATIBILITÉ ===
/// Trouve les propositions compatibles avec une demande
///
/// [demandeId] Identifiant de la demande
/// Retourne [Right(List<PropositionAide>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, List<PropositionAide>>> trouverPropositionsCompatibles(String demandeId);
/// Trouve les demandes compatibles avec une proposition
///
/// [propositionId] Identifiant de la proposition
/// Retourne [Right(List<DemandeAide>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, List<DemandeAide>>> trouverDemandesCompatibles(String propositionId);
/// Recherche des proposants financiers pour une demande approuvée
///
/// [demandeId] Identifiant de la demande
/// Retourne [Right(List<PropositionAide>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, List<PropositionAide>>> rechercherProposantsFinanciers(String demandeId);
// === GESTION DES ÉVALUATIONS ===
/// Crée une nouvelle évaluation
///
/// [evaluation] L'évaluation à créer
/// Retourne [Right(EvaluationAide)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, EvaluationAide>> creerEvaluation(EvaluationAide evaluation);
/// Met à jour une évaluation existante
///
/// [evaluation] L'évaluation à mettre à jour
/// Retourne [Right(EvaluationAide)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, EvaluationAide>> mettreAJourEvaluation(EvaluationAide evaluation);
/// Obtient une évaluation par son ID
///
/// [id] Identifiant de l'évaluation
/// Retourne [Right(EvaluationAide)] si trouvée
/// Retourne [Left(Failure)] si non trouvée ou erreur
Future<Either<Failure, EvaluationAide>> obtenirEvaluation(String id);
/// Obtient les évaluations d'une demande d'aide
///
/// [demandeId] Identifiant de la demande
/// Retourne [Right(List<EvaluationAide>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, List<EvaluationAide>>> obtenirEvaluationsDemande(String demandeId);
/// Obtient les évaluations d'une proposition d'aide
///
/// [propositionId] Identifiant de la proposition
/// Retourne [Right(List<EvaluationAide>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, List<EvaluationAide>>> obtenirEvaluationsProposition(String propositionId);
/// Signale une évaluation comme inappropriée
///
/// [evaluationId] Identifiant de l'évaluation
/// [motif] Motif du signalement
/// Retourne [Right(EvaluationAide)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, EvaluationAide>> signalerEvaluation({
required String evaluationId,
required String motif,
});
// === STATISTIQUES ET ANALYTICS ===
/// Obtient les statistiques de solidarité pour une organisation
///
/// [organisationId] Identifiant de l'organisation
/// Retourne [Right(Map<String, dynamic>)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, Map<String, dynamic>>> obtenirStatistiquesSolidarite(String organisationId);
/// Calcule la note moyenne d'une demande d'aide
///
/// [demandeId] Identifiant de la demande
/// Retourne [Right(StatistiquesEvaluation)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, StatistiquesEvaluation>> calculerMoyenneDemande(String demandeId);
/// Calcule la note moyenne d'une proposition d'aide
///
/// [propositionId] Identifiant de la proposition
/// Retourne [Right(StatistiquesEvaluation)] en cas de succès
/// Retourne [Left(Failure)] en cas d'erreur
Future<Either<Failure, StatistiquesEvaluation>> calculerMoyenneProposition(String propositionId);
}

View File

@@ -0,0 +1,354 @@
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/demande_aide.dart';
import '../repositories/solidarite_repository.dart';
/// Cas d'usage pour créer une nouvelle demande d'aide
class CreerDemandeAideUseCase implements UseCase<DemandeAide, CreerDemandeAideParams> {
final SolidariteRepository repository;
CreerDemandeAideUseCase(this.repository);
@override
Future<Either<Failure, DemandeAide>> call(CreerDemandeAideParams params) async {
return await repository.creerDemandeAide(params.demande);
}
}
class CreerDemandeAideParams {
final DemandeAide demande;
CreerDemandeAideParams({required this.demande});
}
/// Cas d'usage pour mettre à jour une demande d'aide
class MettreAJourDemandeAideUseCase implements UseCase<DemandeAide, MettreAJourDemandeAideParams> {
final SolidariteRepository repository;
MettreAJourDemandeAideUseCase(this.repository);
@override
Future<Either<Failure, DemandeAide>> call(MettreAJourDemandeAideParams params) async {
return await repository.mettreAJourDemandeAide(params.demande);
}
}
class MettreAJourDemandeAideParams {
final DemandeAide demande;
MettreAJourDemandeAideParams({required this.demande});
}
/// Cas d'usage pour obtenir une demande d'aide par ID
class ObtenirDemandeAideUseCase implements UseCase<DemandeAide, ObtenirDemandeAideParams> {
final SolidariteRepository repository;
ObtenirDemandeAideUseCase(this.repository);
@override
Future<Either<Failure, DemandeAide>> call(ObtenirDemandeAideParams params) async {
return await repository.obtenirDemandeAide(params.id);
}
}
class ObtenirDemandeAideParams {
final String id;
ObtenirDemandeAideParams({required this.id});
}
/// Cas d'usage pour soumettre une demande d'aide
class SoumettreDemandeAideUseCase implements UseCase<DemandeAide, SoumettreDemandeAideParams> {
final SolidariteRepository repository;
SoumettreDemandeAideUseCase(this.repository);
@override
Future<Either<Failure, DemandeAide>> call(SoumettreDemandeAideParams params) async {
return await repository.soumettreDemande(params.demandeId);
}
}
class SoumettreDemandeAideParams {
final String demandeId;
SoumettreDemandeAideParams({required this.demandeId});
}
/// Cas d'usage pour évaluer une demande d'aide
class EvaluerDemandeAideUseCase implements UseCase<DemandeAide, EvaluerDemandeAideParams> {
final SolidariteRepository repository;
EvaluerDemandeAideUseCase(this.repository);
@override
Future<Either<Failure, DemandeAide>> call(EvaluerDemandeAideParams params) async {
return await repository.evaluerDemande(
demandeId: params.demandeId,
evaluateurId: params.evaluateurId,
decision: params.decision,
commentaire: params.commentaire,
montantApprouve: params.montantApprouve,
);
}
}
class EvaluerDemandeAideParams {
final String demandeId;
final String evaluateurId;
final StatutAide decision;
final String? commentaire;
final double? montantApprouve;
EvaluerDemandeAideParams({
required this.demandeId,
required this.evaluateurId,
required this.decision,
this.commentaire,
this.montantApprouve,
});
}
/// Cas d'usage pour rechercher des demandes d'aide
class RechercherDemandesAideUseCase implements UseCase<List<DemandeAide>, RechercherDemandesAideParams> {
final SolidariteRepository repository;
RechercherDemandesAideUseCase(this.repository);
@override
Future<Either<Failure, List<DemandeAide>>> call(RechercherDemandesAideParams params) async {
return await repository.rechercherDemandes(
organisationId: params.organisationId,
typeAide: params.typeAide,
statut: params.statut,
demandeurId: params.demandeurId,
urgente: params.urgente,
page: params.page,
taille: params.taille,
);
}
}
class RechercherDemandesAideParams {
final String? organisationId;
final TypeAide? typeAide;
final StatutAide? statut;
final String? demandeurId;
final bool? urgente;
final int page;
final int taille;
RechercherDemandesAideParams({
this.organisationId,
this.typeAide,
this.statut,
this.demandeurId,
this.urgente,
this.page = 0,
this.taille = 20,
});
}
/// Cas d'usage pour obtenir les demandes urgentes
class ObtenirDemandesUrgentesUseCase implements UseCase<List<DemandeAide>, ObtenirDemandesUrgentesParams> {
final SolidariteRepository repository;
ObtenirDemandesUrgentesUseCase(this.repository);
@override
Future<Either<Failure, List<DemandeAide>>> call(ObtenirDemandesUrgentesParams params) async {
return await repository.obtenirDemandesUrgentes(params.organisationId);
}
}
class ObtenirDemandesUrgentesParams {
final String organisationId;
ObtenirDemandesUrgentesParams({required this.organisationId});
}
/// Cas d'usage pour obtenir les demandes de l'utilisateur connecté
class ObtenirMesDemandesUseCase implements UseCase<List<DemandeAide>, ObtenirMesDemandesParams> {
final SolidariteRepository repository;
ObtenirMesDemandesUseCase(this.repository);
@override
Future<Either<Failure, List<DemandeAide>>> call(ObtenirMesDemandesParams params) async {
return await repository.obtenirMesdemandes(params.utilisateurId);
}
}
class ObtenirMesDemandesParams {
final String utilisateurId;
ObtenirMesDemandesParams({required this.utilisateurId});
}
/// Cas d'usage pour valider une demande d'aide avant soumission
class ValiderDemandeAideUseCase implements UseCase<bool, ValiderDemandeAideParams> {
ValiderDemandeAideUseCase();
@override
Future<Either<Failure, bool>> call(ValiderDemandeAideParams params) async {
try {
final demande = params.demande;
final erreurs = <String>[];
// Validation du titre
if (demande.titre.trim().isEmpty) {
erreurs.add('Le titre est obligatoire');
} else if (demande.titre.length < 10) {
erreurs.add('Le titre doit contenir au moins 10 caractères');
} else if (demande.titre.length > 100) {
erreurs.add('Le titre ne peut pas dépasser 100 caractères');
}
// Validation de la description
if (demande.description.trim().isEmpty) {
erreurs.add('La description est obligatoire');
} else if (demande.description.length < 50) {
erreurs.add('La description doit contenir au moins 50 caractères');
} else if (demande.description.length > 1000) {
erreurs.add('La description ne peut pas dépasser 1000 caractères');
}
// Validation du montant pour les aides financières
if (_necessiteMontant(demande.typeAide)) {
if (demande.montantDemande == null) {
erreurs.add('Le montant est obligatoire pour ce type d\'aide');
} else if (demande.montantDemande! <= 0) {
erreurs.add('Le montant doit être supérieur à zéro');
} else if (!_isMontantValide(demande.typeAide, demande.montantDemande!)) {
erreurs.add('Le montant demandé n\'est pas dans la fourchette autorisée');
}
}
// Validation des bénéficiaires
if (demande.beneficiaires.isEmpty) {
erreurs.add('Au moins un bénéficiaire doit être spécifié');
} else {
for (int i = 0; i < demande.beneficiaires.length; i++) {
final beneficiaire = demande.beneficiaires[i];
if (beneficiaire.nom.trim().isEmpty) {
erreurs.add('Le nom du bénéficiaire ${i + 1} est obligatoire');
}
if (beneficiaire.prenom.trim().isEmpty) {
erreurs.add('Le prénom du bénéficiaire ${i + 1} est obligatoire');
}
if (beneficiaire.age < 0 || beneficiaire.age > 120) {
erreurs.add('L\'âge du bénéficiaire ${i + 1} n\'est pas valide');
}
}
}
// Validation de la justification d'urgence si priorité critique ou urgente
if (demande.priorite == PrioriteAide.critique || demande.priorite == PrioriteAide.urgente) {
if (demande.justificationUrgence == null || demande.justificationUrgence!.trim().isEmpty) {
erreurs.add('Une justification d\'urgence est requise pour cette priorité');
} else if (demande.justificationUrgence!.length < 20) {
erreurs.add('La justification d\'urgence doit contenir au moins 20 caractères');
}
}
// Validation du contact d'urgence si priorité critique
if (demande.priorite == PrioriteAide.critique) {
if (demande.contactUrgence == null) {
erreurs.add('Un contact d\'urgence est obligatoire pour les demandes critiques');
} else {
final contact = demande.contactUrgence!;
if (contact.nom.trim().isEmpty) {
erreurs.add('Le nom du contact d\'urgence est obligatoire');
}
if (contact.telephone.trim().isEmpty) {
erreurs.add('Le téléphone du contact d\'urgence est obligatoire');
} else if (!_isValidPhoneNumber(contact.telephone)) {
erreurs.add('Le numéro de téléphone du contact d\'urgence n\'est pas valide');
}
}
}
if (erreurs.isNotEmpty) {
return Left(ValidationFailure(erreurs.join(', ')));
}
return const Right(true);
} catch (e) {
return Left(UnexpectedFailure('Erreur lors de la validation: ${e.toString()}'));
}
}
bool _necessiteMontant(TypeAide typeAide) {
return [
TypeAide.aideFinanciereUrgente,
TypeAide.aideFinanciereMedicale,
TypeAide.aideFinanciereEducation,
].contains(typeAide);
}
bool _isMontantValide(TypeAide typeAide, double montant) {
switch (typeAide) {
case TypeAide.aideFinanciereUrgente:
return montant >= 5000 && montant <= 50000;
case TypeAide.aideFinanciereMedicale:
return montant >= 10000 && montant <= 100000;
case TypeAide.aideFinanciereEducation:
return montant >= 5000 && montant <= 200000;
default:
return true;
}
}
bool _isValidPhoneNumber(String phone) {
// Validation simple pour les numéros de téléphone ivoiriens
final phoneRegex = RegExp(r'^(\+225)?[0-9]{8,10}$');
return phoneRegex.hasMatch(phone.replaceAll(RegExp(r'[\s\-\(\)]'), ''));
}
}
class ValiderDemandeAideParams {
final DemandeAide demande;
ValiderDemandeAideParams({required this.demande});
}
/// Cas d'usage pour calculer la priorité automatique d'une demande
class CalculerPrioriteDemandeUseCase implements UseCase<PrioriteAide, CalculerPrioriteDemandeParams> {
CalculerPrioriteDemandeUseCase();
@override
Future<Either<Failure, PrioriteAide>> call(CalculerPrioriteDemandeParams params) async {
try {
final demande = params.demande;
// Priorité critique si justification d'urgence et contact d'urgence
if (demande.justificationUrgence != null &&
demande.justificationUrgence!.isNotEmpty &&
demande.contactUrgence != null) {
return const Right(PrioriteAide.critique);
}
// Priorité urgente pour certains types d'aide
if ([TypeAide.aideFinanciereUrgente, TypeAide.aideFinanciereMedicale].contains(demande.typeAide)) {
return const Right(PrioriteAide.urgente);
}
// Priorité élevée pour les montants importants
if (demande.montantDemande != null && demande.montantDemande! > 50000) {
return const Right(PrioriteAide.elevee);
}
// Priorité normale par défaut
return const Right(PrioriteAide.normale);
} catch (e) {
return Left(UnexpectedFailure('Erreur lors du calcul de priorité: ${e.toString()}'));
}
}
}
class CalculerPrioriteDemandeParams {
final DemandeAide demande;
CalculerPrioriteDemandeParams({required this.demande});
}

View File

@@ -0,0 +1,463 @@
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/evaluation_aide.dart';
import '../repositories/solidarite_repository.dart';
/// Cas d'usage pour créer une nouvelle évaluation
class CreerEvaluationUseCase implements UseCase<EvaluationAide, CreerEvaluationParams> {
final SolidariteRepository repository;
CreerEvaluationUseCase(this.repository);
@override
Future<Either<Failure, EvaluationAide>> call(CreerEvaluationParams params) async {
return await repository.creerEvaluation(params.evaluation);
}
}
class CreerEvaluationParams {
final EvaluationAide evaluation;
CreerEvaluationParams({required this.evaluation});
}
/// Cas d'usage pour mettre à jour une évaluation
class MettreAJourEvaluationUseCase implements UseCase<EvaluationAide, MettreAJourEvaluationParams> {
final SolidariteRepository repository;
MettreAJourEvaluationUseCase(this.repository);
@override
Future<Either<Failure, EvaluationAide>> call(MettreAJourEvaluationParams params) async {
return await repository.mettreAJourEvaluation(params.evaluation);
}
}
class MettreAJourEvaluationParams {
final EvaluationAide evaluation;
MettreAJourEvaluationParams({required this.evaluation});
}
/// Cas d'usage pour obtenir une évaluation par ID
class ObtenirEvaluationUseCase implements UseCase<EvaluationAide, ObtenirEvaluationParams> {
final SolidariteRepository repository;
ObtenirEvaluationUseCase(this.repository);
@override
Future<Either<Failure, EvaluationAide>> call(ObtenirEvaluationParams params) async {
return await repository.obtenirEvaluation(params.id);
}
}
class ObtenirEvaluationParams {
final String id;
ObtenirEvaluationParams({required this.id});
}
/// Cas d'usage pour obtenir les évaluations d'une demande
class ObtenirEvaluationsDemandeUseCase implements UseCase<List<EvaluationAide>, ObtenirEvaluationsDemandeParams> {
final SolidariteRepository repository;
ObtenirEvaluationsDemandeUseCase(this.repository);
@override
Future<Either<Failure, List<EvaluationAide>>> call(ObtenirEvaluationsDemandeParams params) async {
return await repository.obtenirEvaluationsDemande(params.demandeId);
}
}
class ObtenirEvaluationsDemandeParams {
final String demandeId;
ObtenirEvaluationsDemandeParams({required this.demandeId});
}
/// Cas d'usage pour obtenir les évaluations d'une proposition
class ObtenirEvaluationsPropositionUseCase implements UseCase<List<EvaluationAide>, ObtenirEvaluationsPropositionParams> {
final SolidariteRepository repository;
ObtenirEvaluationsPropositionUseCase(this.repository);
@override
Future<Either<Failure, List<EvaluationAide>>> call(ObtenirEvaluationsPropositionParams params) async {
return await repository.obtenirEvaluationsProposition(params.propositionId);
}
}
class ObtenirEvaluationsPropositionParams {
final String propositionId;
ObtenirEvaluationsPropositionParams({required this.propositionId});
}
/// Cas d'usage pour signaler une évaluation
class SignalerEvaluationUseCase implements UseCase<EvaluationAide, SignalerEvaluationParams> {
final SolidariteRepository repository;
SignalerEvaluationUseCase(this.repository);
@override
Future<Either<Failure, EvaluationAide>> call(SignalerEvaluationParams params) async {
return await repository.signalerEvaluation(
evaluationId: params.evaluationId,
motif: params.motif,
);
}
}
class SignalerEvaluationParams {
final String evaluationId;
final String motif;
SignalerEvaluationParams({
required this.evaluationId,
required this.motif,
});
}
/// Cas d'usage pour calculer la note moyenne d'une demande
class CalculerMoyenneDemandeUseCase implements UseCase<StatistiquesEvaluation, CalculerMoyenneDemandeParams> {
final SolidariteRepository repository;
CalculerMoyenneDemandeUseCase(this.repository);
@override
Future<Either<Failure, StatistiquesEvaluation>> call(CalculerMoyenneDemandeParams params) async {
return await repository.calculerMoyenneDemande(params.demandeId);
}
}
class CalculerMoyenneDemandeParams {
final String demandeId;
CalculerMoyenneDemandeParams({required this.demandeId});
}
/// Cas d'usage pour calculer la note moyenne d'une proposition
class CalculerMoyennePropositionUseCase implements UseCase<StatistiquesEvaluation, CalculerMoyennePropositionParams> {
final SolidariteRepository repository;
CalculerMoyennePropositionUseCase(this.repository);
@override
Future<Either<Failure, StatistiquesEvaluation>> call(CalculerMoyennePropositionParams params) async {
return await repository.calculerMoyenneProposition(params.propositionId);
}
}
class CalculerMoyennePropositionParams {
final String propositionId;
CalculerMoyennePropositionParams({required this.propositionId});
}
/// Cas d'usage pour valider une évaluation avant création
class ValiderEvaluationUseCase implements UseCase<bool, ValiderEvaluationParams> {
ValiderEvaluationUseCase();
@override
Future<Either<Failure, bool>> call(ValiderEvaluationParams params) async {
try {
final evaluation = params.evaluation;
final erreurs = <String>[];
// Validation de la note globale
if (evaluation.noteGlobale < 1.0 || evaluation.noteGlobale > 5.0) {
erreurs.add('La note globale doit être comprise entre 1 et 5');
}
// Validation des notes détaillées
final notesDetaillees = [
evaluation.noteDelaiReponse,
evaluation.noteCommunication,
evaluation.noteProfessionnalisme,
evaluation.noteRespectEngagements,
];
for (final note in notesDetaillees) {
if (note != null && (note < 1.0 || note > 5.0)) {
erreurs.add('Toutes les notes détaillées doivent être comprises entre 1 et 5');
break;
}
}
// Validation du commentaire principal
if (evaluation.commentairePrincipal.trim().isEmpty) {
erreurs.add('Le commentaire principal est obligatoire');
} else if (evaluation.commentairePrincipal.length < 20) {
erreurs.add('Le commentaire principal doit contenir au moins 20 caractères');
} else if (evaluation.commentairePrincipal.length > 1000) {
erreurs.add('Le commentaire principal ne peut pas dépasser 1000 caractères');
}
// Validation de la cohérence entre note et commentaire
if (evaluation.noteGlobale <= 2.0 && evaluation.commentairePrincipal.length < 50) {
erreurs.add('Un commentaire détaillé est requis pour les notes faibles');
}
// Validation des points positifs et d'amélioration
if (evaluation.pointsPositifs != null && evaluation.pointsPositifs!.length > 500) {
erreurs.add('Les points positifs ne peuvent pas dépasser 500 caractères');
}
if (evaluation.pointsAmelioration != null && evaluation.pointsAmelioration!.length > 500) {
erreurs.add('Les points d\'amélioration ne peuvent pas dépasser 500 caractères');
}
// Validation des recommandations
if (evaluation.recommandations != null && evaluation.recommandations!.length > 500) {
erreurs.add('Les recommandations ne peuvent pas dépasser 500 caractères');
}
// Validation de la cohérence de la recommandation
if (evaluation.recommande == true && evaluation.noteGlobale < 3.0) {
erreurs.add('Impossible de recommander avec une note inférieure à 3');
}
if (evaluation.recommande == false && evaluation.noteGlobale >= 4.0) {
erreurs.add('Une note de 4 ou plus devrait normalement être recommandée');
}
// Détection de contenu inapproprié
if (_contientContenuInapproprie(evaluation.commentairePrincipal)) {
erreurs.add('Le commentaire contient du contenu inapproprié');
}
if (erreurs.isNotEmpty) {
return Left(ValidationFailure(erreurs.join(', ')));
}
return const Right(true);
} catch (e) {
return Left(UnexpectedFailure('Erreur lors de la validation: ${e.toString()}'));
}
}
bool _contientContenuInapproprie(String texte) {
// Liste simple de mots inappropriés (à étendre selon les besoins)
final motsInappropries = [
'spam', 'arnaque', 'escroquerie', 'fraude',
// Ajouter d'autres mots selon le contexte
];
final texteMinuscule = texte.toLowerCase();
return motsInappropries.any((mot) => texteMinuscule.contains(mot));
}
}
class ValiderEvaluationParams {
final EvaluationAide evaluation;
ValiderEvaluationParams({required this.evaluation});
}
/// Cas d'usage pour calculer le score de qualité d'une évaluation
class CalculerScoreQualiteEvaluationUseCase implements UseCase<double, CalculerScoreQualiteEvaluationParams> {
CalculerScoreQualiteEvaluationUseCase();
@override
Future<Either<Failure, double>> call(CalculerScoreQualiteEvaluationParams params) async {
try {
final evaluation = params.evaluation;
double score = 50.0; // Score de base
// Bonus pour la longueur du commentaire
final longueurCommentaire = evaluation.commentairePrincipal.length;
if (longueurCommentaire >= 100) {
score += 15.0;
} else if (longueurCommentaire >= 50) {
score += 10.0;
} else if (longueurCommentaire >= 20) {
score += 5.0;
}
// Bonus pour les notes détaillées
final notesDetaillees = [
evaluation.noteDelaiReponse,
evaluation.noteCommunication,
evaluation.noteProfessionnalisme,
evaluation.noteRespectEngagements,
];
final nombreNotesDetaillees = notesDetaillees.where((note) => note != null).length;
score += nombreNotesDetaillees * 5.0; // 5 points par note détaillée
// Bonus pour les sections optionnelles remplies
if (evaluation.pointsPositifs != null && evaluation.pointsPositifs!.isNotEmpty) {
score += 5.0;
}
if (evaluation.pointsAmelioration != null && evaluation.pointsAmelioration!.isNotEmpty) {
score += 5.0;
}
if (evaluation.recommandations != null && evaluation.recommandations!.isNotEmpty) {
score += 5.0;
}
// Bonus pour la cohérence
if (_estCoherente(evaluation)) {
score += 10.0;
}
// Malus pour les évaluations extrêmes sans justification
if ((evaluation.noteGlobale <= 1.5 || evaluation.noteGlobale >= 4.5) &&
longueurCommentaire < 50) {
score -= 15.0;
}
// Malus pour les signalements
score -= evaluation.nombreSignalements * 10.0;
return Right(score.clamp(0.0, 100.0));
} catch (e) {
return Left(UnexpectedFailure('Erreur lors du calcul du score de qualité: ${e.toString()}'));
}
}
bool _estCoherente(EvaluationAide evaluation) {
// Vérifier la cohérence entre la note globale et les notes détaillées
final notesDetaillees = [
evaluation.noteDelaiReponse,
evaluation.noteCommunication,
evaluation.noteProfessionnalisme,
evaluation.noteRespectEngagements,
].where((note) => note != null).cast<double>().toList();
if (notesDetaillees.isEmpty) return true;
final moyenneDetaillees = notesDetaillees.reduce((a, b) => a + b) / notesDetaillees.length;
final ecart = (evaluation.noteGlobale - moyenneDetaillees).abs();
// Cohérent si l'écart est inférieur à 1 point
return ecart < 1.0;
}
}
class CalculerScoreQualiteEvaluationParams {
final EvaluationAide evaluation;
CalculerScoreQualiteEvaluationParams({required this.evaluation});
}
/// Cas d'usage pour analyser les tendances d'évaluation
class AnalyserTendancesEvaluationUseCase implements UseCase<AnalyseTendancesEvaluation, AnalyserTendancesEvaluationParams> {
AnalyserTendancesEvaluationUseCase();
@override
Future<Either<Failure, AnalyseTendancesEvaluation>> call(AnalyserTendancesEvaluationParams params) async {
try {
// Simulation d'analyse des tendances d'évaluation
// Dans une vraie implémentation, on analyserait les données historiques
final analyse = AnalyseTendancesEvaluation(
noteMoyenneGlobale: 4.2,
nombreTotalEvaluations: 1247,
repartitionNotes: {
5: 456,
4: 523,
3: 189,
2: 58,
1: 21,
},
pourcentageRecommandations: 78.5,
tempsReponseEvaluationMoyen: const Duration(days: 3),
criteresLesMieuxNotes: [
CritereNote('Respect des engagements', 4.6),
CritereNote('Communication', 4.3),
CritereNote('Professionnalisme', 4.1),
CritereNote('Délai de réponse', 3.9),
],
typeEvaluateursPlusActifs: [
TypeEvaluateurActivite(TypeEvaluateur.beneficiaire, 67.2),
TypeEvaluateurActivite(TypeEvaluateur.proposant, 23.8),
TypeEvaluateurActivite(TypeEvaluateur.evaluateurOfficial, 6.5),
TypeEvaluateurActivite(TypeEvaluateur.administrateur, 2.5),
],
evolutionSatisfaction: EvolutionSatisfaction(
dernierMois: 4.2,
moisPrecedent: 4.0,
tendance: TendanceSatisfaction.hausse,
),
recommandationsAmelioration: [
'Améliorer les délais de réponse des proposants',
'Encourager plus d\'évaluations détaillées',
'Former les proposants à la communication',
],
);
return Right(analyse);
} catch (e) {
return Left(UnexpectedFailure('Erreur lors de l\'analyse des tendances: ${e.toString()}'));
}
}
}
class AnalyserTendancesEvaluationParams {
final String organisationId;
final DateTime? dateDebut;
final DateTime? dateFin;
AnalyserTendancesEvaluationParams({
required this.organisationId,
this.dateDebut,
this.dateFin,
});
}
/// Classes pour l'analyse des tendances d'évaluation
class AnalyseTendancesEvaluation {
final double noteMoyenneGlobale;
final int nombreTotalEvaluations;
final Map<int, int> repartitionNotes;
final double pourcentageRecommandations;
final Duration tempsReponseEvaluationMoyen;
final List<CritereNote> criteresLesMieuxNotes;
final List<TypeEvaluateurActivite> typeEvaluateursPlusActifs;
final EvolutionSatisfaction evolutionSatisfaction;
final List<String> recommandationsAmelioration;
const AnalyseTendancesEvaluation({
required this.noteMoyenneGlobale,
required this.nombreTotalEvaluations,
required this.repartitionNotes,
required this.pourcentageRecommandations,
required this.tempsReponseEvaluationMoyen,
required this.criteresLesMieuxNotes,
required this.typeEvaluateursPlusActifs,
required this.evolutionSatisfaction,
required this.recommandationsAmelioration,
});
}
class CritereNote {
final String nom;
final double noteMoyenne;
const CritereNote(this.nom, this.noteMoyenne);
}
class TypeEvaluateurActivite {
final TypeEvaluateur type;
final double pourcentage;
const TypeEvaluateurActivite(this.type, this.pourcentage);
}
class EvolutionSatisfaction {
final double dernierMois;
final double moisPrecedent;
final TendanceSatisfaction tendance;
const EvolutionSatisfaction({
required this.dernierMois,
required this.moisPrecedent,
required this.tendance,
});
}
enum TendanceSatisfaction { hausse, baisse, stable }

View File

@@ -0,0 +1,391 @@
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/demande_aide.dart';
import '../entities/proposition_aide.dart';
import '../repositories/solidarite_repository.dart';
/// Cas d'usage pour trouver les propositions compatibles avec une demande
class TrouverPropositionsCompatiblesUseCase implements UseCase<List<PropositionAide>, TrouverPropositionsCompatiblesParams> {
final SolidariteRepository repository;
TrouverPropositionsCompatiblesUseCase(this.repository);
@override
Future<Either<Failure, List<PropositionAide>>> call(TrouverPropositionsCompatiblesParams params) async {
return await repository.trouverPropositionsCompatibles(params.demandeId);
}
}
class TrouverPropositionsCompatiblesParams {
final String demandeId;
TrouverPropositionsCompatiblesParams({required this.demandeId});
}
/// Cas d'usage pour trouver les demandes compatibles avec une proposition
class TrouverDemandesCompatiblesUseCase implements UseCase<List<DemandeAide>, TrouverDemandesCompatiblesParams> {
final SolidariteRepository repository;
TrouverDemandesCompatiblesUseCase(this.repository);
@override
Future<Either<Failure, List<DemandeAide>>> call(TrouverDemandesCompatiblesParams params) async {
return await repository.trouverDemandesCompatibles(params.propositionId);
}
}
class TrouverDemandesCompatiblesParams {
final String propositionId;
TrouverDemandesCompatiblesParams({required this.propositionId});
}
/// Cas d'usage pour rechercher des proposants financiers
class RechercherProposantsFinanciersUseCase implements UseCase<List<PropositionAide>, RechercherProposantsFinanciersParams> {
final SolidariteRepository repository;
RechercherProposantsFinanciersUseCase(this.repository);
@override
Future<Either<Failure, List<PropositionAide>>> call(RechercherProposantsFinanciersParams params) async {
return await repository.rechercherProposantsFinanciers(params.demandeId);
}
}
class RechercherProposantsFinanciersParams {
final String demandeId;
RechercherProposantsFinanciersParams({required this.demandeId});
}
/// Cas d'usage pour calculer le score de compatibilité entre une demande et une proposition
class CalculerScoreCompatibiliteUseCase implements UseCase<double, CalculerScoreCompatibiliteParams> {
CalculerScoreCompatibiliteUseCase();
@override
Future<Either<Failure, double>> call(CalculerScoreCompatibiliteParams params) async {
try {
final demande = params.demande;
final proposition = params.proposition;
double score = 0.0;
// 1. Correspondance du type d'aide (40 points max)
if (demande.typeAide == proposition.typeAide) {
score += 40.0;
} else if (_sontTypesCompatibles(demande.typeAide, proposition.typeAide)) {
score += 25.0;
} else if (proposition.typeAide == TypeAide.autre) {
score += 15.0;
}
// 2. Compatibilité financière (25 points max)
if (_necessiteMontant(demande.typeAide) && proposition.montantMaximum != null) {
final montantDemande = demande.montantDemande;
if (montantDemande != null) {
if (montantDemande <= proposition.montantMaximum!) {
score += 25.0;
} else {
// Pénalité proportionnelle au dépassement
double ratio = proposition.montantMaximum! / montantDemande;
score += 25.0 * ratio;
}
}
} else if (!_necessiteMontant(demande.typeAide)) {
score += 25.0; // Pas de contrainte financière
}
// 3. Expérience du proposant (15 points max)
if (proposition.nombreBeneficiairesAides > 0) {
score += (proposition.nombreBeneficiairesAides * 2.0).clamp(0.0, 15.0);
}
// 4. Réputation (10 points max)
if (proposition.noteMoyenne != null && proposition.nombreEvaluations >= 3) {
score += (proposition.noteMoyenne! - 3.0) * 3.33;
}
// 5. Disponibilité et capacité (10 points max)
if (proposition.peutAccepterBeneficiaires) {
double ratioCapacite = proposition.placesRestantes / proposition.nombreMaxBeneficiaires;
score += 10.0 * ratioCapacite;
}
// Bonus et malus additionnels
score += _calculerBonusGeographique(demande, proposition);
score += _calculerBonusTemporel(demande, proposition);
score -= _calculerMalusDelai(demande, proposition);
return Right(score.clamp(0.0, 100.0));
} catch (e) {
return Left(UnexpectedFailure('Erreur lors du calcul de compatibilité: ${e.toString()}'));
}
}
bool _sontTypesCompatibles(TypeAide typeAide1, TypeAide typeAide2) {
// Définir les groupes de types compatibles
final groupesCompatibles = [
[TypeAide.aideFinanciereUrgente, TypeAide.aideFinanciereMedicale, TypeAide.aideFinanciereEducation],
[TypeAide.aideMaterielleVetements, TypeAide.aideMaterielleNourriture],
[TypeAide.aideProfessionnelleFormation, TypeAide.aideSocialeAccompagnement],
];
for (final groupe in groupesCompatibles) {
if (groupe.contains(typeAide1) && groupe.contains(typeAide2)) {
return true;
}
}
return false;
}
bool _necessiteMontant(TypeAide typeAide) {
return [
TypeAide.aideFinanciereUrgente,
TypeAide.aideFinanciereMedicale,
TypeAide.aideFinanciereEducation,
].contains(typeAide);
}
double _calculerBonusGeographique(DemandeAide demande, PropositionAide proposition) {
// Simulation - dans une vraie implémentation, on utiliserait les données de localisation
if (demande.localisation != null && proposition.zonesGeographiques.isNotEmpty) {
// Logique de proximité géographique
return 5.0;
}
return 0.0;
}
double _calculerBonusTemporel(DemandeAide demande, PropositionAide proposition) {
double bonus = 0.0;
// Bonus pour demande urgente
if (demande.estUrgente) {
bonus += 5.0;
}
// Bonus pour proposition récente
final joursDepuisCreation = DateTime.now().difference(proposition.dateCreation).inDays;
if (joursDepuisCreation <= 30) {
bonus += 3.0;
}
return bonus;
}
double _calculerMalusDelai(DemandeAide demande, PropositionAide proposition) {
double malus = 0.0;
// Malus si la demande est en retard
if (demande.delaiDepasse) {
malus += 5.0;
}
// Malus si la proposition a un délai de réponse long
if (proposition.delaiReponseHeures > 168) { // Plus d'une semaine
malus += 3.0;
}
return malus;
}
}
class CalculerScoreCompatibiliteParams {
final DemandeAide demande;
final PropositionAide proposition;
CalculerScoreCompatibiliteParams({
required this.demande,
required this.proposition,
});
}
/// Cas d'usage pour effectuer un matching intelligent
class EffectuerMatchingIntelligentUseCase implements UseCase<List<ResultatMatching>, EffectuerMatchingIntelligentParams> {
final TrouverPropositionsCompatiblesUseCase trouverPropositionsCompatibles;
final CalculerScoreCompatibiliteUseCase calculerScoreCompatibilite;
EffectuerMatchingIntelligentUseCase({
required this.trouverPropositionsCompatibles,
required this.calculerScoreCompatibilite,
});
@override
Future<Either<Failure, List<ResultatMatching>>> call(EffectuerMatchingIntelligentParams params) async {
try {
// 1. Trouver les propositions compatibles
final propositionsResult = await trouverPropositionsCompatibles(
TrouverPropositionsCompatiblesParams(demandeId: params.demande.id)
);
return propositionsResult.fold(
(failure) => Left(failure),
(propositions) async {
// 2. Calculer les scores de compatibilité
final resultats = <ResultatMatching>[];
for (final proposition in propositions) {
final scoreResult = await calculerScoreCompatibilite(
CalculerScoreCompatibiliteParams(
demande: params.demande,
proposition: proposition,
)
);
scoreResult.fold(
(failure) {
// Ignorer les erreurs de calcul de score individuel
},
(score) {
if (score >= params.scoreMinimum) {
resultats.add(ResultatMatching(
proposition: proposition,
score: score,
raisonCompatibilite: _genererRaisonCompatibilite(params.demande, proposition, score),
));
}
},
);
}
// 3. Trier par score décroissant
resultats.sort((a, b) => b.score.compareTo(a.score));
// 4. Limiter le nombre de résultats
final resultatsLimites = resultats.take(params.limiteResultats).toList();
return Right(resultatsLimites);
},
);
} catch (e) {
return Left(UnexpectedFailure('Erreur lors du matching intelligent: ${e.toString()}'));
}
}
String _genererRaisonCompatibilite(DemandeAide demande, PropositionAide proposition, double score) {
final raisons = <String>[];
// Type d'aide
if (demande.typeAide == proposition.typeAide) {
raisons.add('Type d\'aide identique');
}
// Compatibilité financière
if (demande.montantDemande != null && proposition.montantMaximum != null) {
if (demande.montantDemande! <= proposition.montantMaximum!) {
raisons.add('Montant compatible');
}
}
// Expérience
if (proposition.nombreBeneficiairesAides > 5) {
raisons.add('Proposant expérimenté');
}
// Réputation
if (proposition.noteMoyenne != null && proposition.noteMoyenne! >= 4.0) {
raisons.add('Excellente réputation');
}
// Disponibilité
if (proposition.peutAccepterBeneficiaires) {
raisons.add('Places disponibles');
}
return raisons.isEmpty ? 'Compatible' : raisons.join(', ');
}
}
class EffectuerMatchingIntelligentParams {
final DemandeAide demande;
final double scoreMinimum;
final int limiteResultats;
EffectuerMatchingIntelligentParams({
required this.demande,
this.scoreMinimum = 30.0,
this.limiteResultats = 10,
});
}
/// Classe représentant un résultat de matching
class ResultatMatching {
final PropositionAide proposition;
final double score;
final String raisonCompatibilite;
const ResultatMatching({
required this.proposition,
required this.score,
required this.raisonCompatibilite,
});
}
/// Cas d'usage pour analyser les tendances de matching
class AnalyserTendancesMatchingUseCase implements UseCase<AnalyseTendances, AnalyserTendancesMatchingParams> {
AnalyserTendancesMatchingUseCase();
@override
Future<Either<Failure, AnalyseTendances>> call(AnalyserTendancesMatchingParams params) async {
try {
// Simulation d'analyse des tendances
// Dans une vraie implémentation, on analyserait les données historiques
final analyse = AnalyseTendances(
tauxMatchingMoyen: 78.5,
tempsMatchingMoyen: const Duration(hours: 6),
typesAidePlusDemandesMap: {
TypeAide.aideFinanciereUrgente: 45,
TypeAide.aideFinanciereMedicale: 32,
TypeAide.aideMaterielleNourriture: 28,
},
typesAidePlusProposesMap: {
TypeAide.aideFinanciereEducation: 38,
TypeAide.aideProfessionnelleFormation: 25,
TypeAide.aideSocialeAccompagnement: 22,
},
heuresOptimalesMatching: ['09:00', '14:00', '18:00'],
recommandations: [
'Augmenter les propositions d\'aide financière urgente',
'Promouvoir les aides matérielles auprès des proposants',
'Optimiser les notifications entre 9h et 18h',
],
);
return Right(analyse);
} catch (e) {
return Left(UnexpectedFailure('Erreur lors de l\'analyse des tendances: ${e.toString()}'));
}
}
}
class AnalyserTendancesMatchingParams {
final String organisationId;
final DateTime? dateDebut;
final DateTime? dateFin;
AnalyserTendancesMatchingParams({
required this.organisationId,
this.dateDebut,
this.dateFin,
});
}
/// Classe représentant une analyse des tendances de matching
class AnalyseTendances {
final double tauxMatchingMoyen;
final Duration tempsMatchingMoyen;
final Map<TypeAide, int> typesAidePlusDemandesMap;
final Map<TypeAide, int> typesAidePlusProposesMap;
final List<String> heuresOptimalesMatching;
final List<String> recommandations;
const AnalyseTendances({
required this.tauxMatchingMoyen,
required this.tempsMatchingMoyen,
required this.typesAidePlusDemandesMap,
required this.typesAidePlusProposesMap,
required this.heuresOptimalesMatching,
required this.recommandations,
});
}

View File

@@ -0,0 +1,394 @@
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/proposition_aide.dart';
import '../entities/demande_aide.dart';
import '../repositories/solidarite_repository.dart';
/// Cas d'usage pour créer une nouvelle proposition d'aide
class CreerPropositionAideUseCase implements UseCase<PropositionAide, CreerPropositionAideParams> {
final SolidariteRepository repository;
CreerPropositionAideUseCase(this.repository);
@override
Future<Either<Failure, PropositionAide>> call(CreerPropositionAideParams params) async {
return await repository.creerPropositionAide(params.proposition);
}
}
class CreerPropositionAideParams {
final PropositionAide proposition;
CreerPropositionAideParams({required this.proposition});
}
/// Cas d'usage pour mettre à jour une proposition d'aide
class MettreAJourPropositionAideUseCase implements UseCase<PropositionAide, MettreAJourPropositionAideParams> {
final SolidariteRepository repository;
MettreAJourPropositionAideUseCase(this.repository);
@override
Future<Either<Failure, PropositionAide>> call(MettreAJourPropositionAideParams params) async {
return await repository.mettreAJourPropositionAide(params.proposition);
}
}
class MettreAJourPropositionAideParams {
final PropositionAide proposition;
MettreAJourPropositionAideParams({required this.proposition});
}
/// Cas d'usage pour obtenir une proposition d'aide par ID
class ObtenirPropositionAideUseCase implements UseCase<PropositionAide, ObtenirPropositionAideParams> {
final SolidariteRepository repository;
ObtenirPropositionAideUseCase(this.repository);
@override
Future<Either<Failure, PropositionAide>> call(ObtenirPropositionAideParams params) async {
return await repository.obtenirPropositionAide(params.id);
}
}
class ObtenirPropositionAideParams {
final String id;
ObtenirPropositionAideParams({required this.id});
}
/// Cas d'usage pour changer le statut d'une proposition d'aide
class ChangerStatutPropositionUseCase implements UseCase<PropositionAide, ChangerStatutPropositionParams> {
final SolidariteRepository repository;
ChangerStatutPropositionUseCase(this.repository);
@override
Future<Either<Failure, PropositionAide>> call(ChangerStatutPropositionParams params) async {
return await repository.changerStatutProposition(
propositionId: params.propositionId,
activer: params.activer,
);
}
}
class ChangerStatutPropositionParams {
final String propositionId;
final bool activer;
ChangerStatutPropositionParams({
required this.propositionId,
required this.activer,
});
}
/// Cas d'usage pour rechercher des propositions d'aide
class RechercherPropositionsAideUseCase implements UseCase<List<PropositionAide>, RechercherPropositionsAideParams> {
final SolidariteRepository repository;
RechercherPropositionsAideUseCase(this.repository);
@override
Future<Either<Failure, List<PropositionAide>>> call(RechercherPropositionsAideParams params) async {
return await repository.rechercherPropositions(
organisationId: params.organisationId,
typeAide: params.typeAide,
proposantId: params.proposantId,
actives: params.actives,
page: params.page,
taille: params.taille,
);
}
}
class RechercherPropositionsAideParams {
final String? organisationId;
final TypeAide? typeAide;
final String? proposantId;
final bool? actives;
final int page;
final int taille;
RechercherPropositionsAideParams({
this.organisationId,
this.typeAide,
this.proposantId,
this.actives,
this.page = 0,
this.taille = 20,
});
}
/// Cas d'usage pour obtenir les propositions actives pour un type d'aide
class ObtenirPropositionsActivesUseCase implements UseCase<List<PropositionAide>, ObtenirPropositionsActivesParams> {
final SolidariteRepository repository;
ObtenirPropositionsActivesUseCase(this.repository);
@override
Future<Either<Failure, List<PropositionAide>>> call(ObtenirPropositionsActivesParams params) async {
return await repository.obtenirPropositionsActives(params.typeAide);
}
}
class ObtenirPropositionsActivesParams {
final TypeAide typeAide;
ObtenirPropositionsActivesParams({required this.typeAide});
}
/// Cas d'usage pour obtenir les meilleures propositions
class ObtenirMeilleuresPropositionsUseCase implements UseCase<List<PropositionAide>, ObtenirMeilleuresPropositionsParams> {
final SolidariteRepository repository;
ObtenirMeilleuresPropositionsUseCase(this.repository);
@override
Future<Either<Failure, List<PropositionAide>>> call(ObtenirMeilleuresPropositionsParams params) async {
return await repository.obtenirMeilleuresPropositions(params.limite);
}
}
class ObtenirMeilleuresPropositionsParams {
final int limite;
ObtenirMeilleuresPropositionsParams({this.limite = 10});
}
/// Cas d'usage pour obtenir les propositions de l'utilisateur connecté
class ObtenirMesPropositionsUseCase implements UseCase<List<PropositionAide>, ObtenirMesPropositionsParams> {
final SolidariteRepository repository;
ObtenirMesPropositionsUseCase(this.repository);
@override
Future<Either<Failure, List<PropositionAide>>> call(ObtenirMesPropositionsParams params) async {
return await repository.obtenirMesPropositions(params.utilisateurId);
}
}
class ObtenirMesPropositionsParams {
final String utilisateurId;
ObtenirMesPropositionsParams({required this.utilisateurId});
}
/// Cas d'usage pour valider une proposition d'aide avant création
class ValiderPropositionAideUseCase implements UseCase<bool, ValiderPropositionAideParams> {
ValiderPropositionAideUseCase();
@override
Future<Either<Failure, bool>> call(ValiderPropositionAideParams params) async {
try {
final proposition = params.proposition;
final erreurs = <String>[];
// Validation du titre
if (proposition.titre.trim().isEmpty) {
erreurs.add('Le titre est obligatoire');
} else if (proposition.titre.length < 10) {
erreurs.add('Le titre doit contenir au moins 10 caractères');
} else if (proposition.titre.length > 100) {
erreurs.add('Le titre ne peut pas dépasser 100 caractères');
}
// Validation de la description
if (proposition.description.trim().isEmpty) {
erreurs.add('La description est obligatoire');
} else if (proposition.description.length < 50) {
erreurs.add('La description doit contenir au moins 50 caractères');
} else if (proposition.description.length > 1000) {
erreurs.add('La description ne peut pas dépasser 1000 caractères');
}
// Validation du nombre maximum de bénéficiaires
if (proposition.nombreMaxBeneficiaires <= 0) {
erreurs.add('Le nombre maximum de bénéficiaires doit être supérieur à zéro');
} else if (proposition.nombreMaxBeneficiaires > 100) {
erreurs.add('Le nombre maximum de bénéficiaires ne peut pas dépasser 100');
}
// Validation des montants pour les aides financières
if (_estAideFinanciere(proposition.typeAide)) {
if (proposition.montantMaximum == null) {
erreurs.add('Le montant maximum est obligatoire pour les aides financières');
} else if (proposition.montantMaximum! <= 0) {
erreurs.add('Le montant maximum doit être supérieur à zéro');
} else if (proposition.montantMaximum! > 1000000) {
erreurs.add('Le montant maximum ne peut pas dépasser 1 000 000 FCFA');
}
if (proposition.montantMinimum != null) {
if (proposition.montantMinimum! <= 0) {
erreurs.add('Le montant minimum doit être supérieur à zéro');
} else if (proposition.montantMaximum != null &&
proposition.montantMinimum! >= proposition.montantMaximum!) {
erreurs.add('Le montant minimum doit être inférieur au montant maximum');
}
}
}
// Validation du délai de réponse
if (proposition.delaiReponseHeures <= 0) {
erreurs.add('Le délai de réponse doit être supérieur à zéro');
} else if (proposition.delaiReponseHeures > 720) { // 30 jours max
erreurs.add('Le délai de réponse ne peut pas dépasser 30 jours');
}
// Validation du contact proposant
final contact = proposition.contactProposant;
if (contact.nom.trim().isEmpty) {
erreurs.add('Le nom du contact est obligatoire');
}
if (contact.telephone.trim().isEmpty) {
erreurs.add('Le téléphone du contact est obligatoire');
} else if (!_isValidPhoneNumber(contact.telephone)) {
erreurs.add('Le numéro de téléphone n\'est pas valide');
}
// Validation de l'email si fourni
if (contact.email != null && contact.email!.isNotEmpty) {
if (!_isValidEmail(contact.email!)) {
erreurs.add('L\'adresse email n\'est pas valide');
}
}
// Validation des zones géographiques
if (proposition.zonesGeographiques.isEmpty) {
erreurs.add('Au moins une zone géographique doit être spécifiée');
}
// Validation des créneaux de disponibilité
if (proposition.creneauxDisponibilite.isEmpty) {
erreurs.add('Au moins un créneau de disponibilité doit être spécifié');
} else {
for (int i = 0; i < proposition.creneauxDisponibilite.length; i++) {
final creneau = proposition.creneauxDisponibilite[i];
if (!_isValidTimeFormat(creneau.heureDebut)) {
erreurs.add('L\'heure de début du créneau ${i + 1} n\'est pas valide (format HH:MM)');
}
if (!_isValidTimeFormat(creneau.heureFin)) {
erreurs.add('L\'heure de fin du créneau ${i + 1} n\'est pas valide (format HH:MM)');
}
if (_isValidTimeFormat(creneau.heureDebut) &&
_isValidTimeFormat(creneau.heureFin) &&
_compareTime(creneau.heureDebut, creneau.heureFin) >= 0) {
erreurs.add('L\'heure de fin du créneau ${i + 1} doit être après l\'heure de début');
}
}
}
// Validation de la date d'expiration
if (proposition.dateExpiration != null) {
if (proposition.dateExpiration!.isBefore(DateTime.now())) {
erreurs.add('La date d\'expiration ne peut pas être dans le passé');
} else if (proposition.dateExpiration!.isAfter(DateTime.now().add(const Duration(days: 365)))) {
erreurs.add('La date d\'expiration ne peut pas dépasser un an');
}
}
if (erreurs.isNotEmpty) {
return Left(ValidationFailure(erreurs.join(', ')));
}
return const Right(true);
} catch (e) {
return Left(UnexpectedFailure('Erreur lors de la validation: ${e.toString()}'));
}
}
bool _estAideFinanciere(TypeAide typeAide) {
return [
TypeAide.aideFinanciereUrgente,
TypeAide.aideFinanciereMedicale,
TypeAide.aideFinanciereEducation,
].contains(typeAide);
}
bool _isValidPhoneNumber(String phone) {
final phoneRegex = RegExp(r'^(\+225)?[0-9]{8,10}$');
return phoneRegex.hasMatch(phone.replaceAll(RegExp(r'[\s\-\(\)]'), ''));
}
bool _isValidEmail(String email) {
final emailRegex = RegExp(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$');
return emailRegex.hasMatch(email);
}
bool _isValidTimeFormat(String time) {
final timeRegex = RegExp(r'^([01]?[0-9]|2[0-3]):[0-5][0-9]$');
return timeRegex.hasMatch(time);
}
int _compareTime(String time1, String time2) {
final parts1 = time1.split(':');
final parts2 = time2.split(':');
final minutes1 = int.parse(parts1[0]) * 60 + int.parse(parts1[1]);
final minutes2 = int.parse(parts2[0]) * 60 + int.parse(parts2[1]);
return minutes1.compareTo(minutes2);
}
}
class ValiderPropositionAideParams {
final PropositionAide proposition;
ValiderPropositionAideParams({required this.proposition});
}
/// Cas d'usage pour calculer le score de pertinence d'une proposition
class CalculerScorePropositionUseCase implements UseCase<double, CalculerScorePropositionParams> {
CalculerScorePropositionUseCase();
@override
Future<Either<Failure, double>> call(CalculerScorePropositionParams params) async {
try {
final proposition = params.proposition;
double score = 50.0; // Score de base
// Bonus pour l'expérience (nombre d'aides réalisées)
score += (proposition.nombreBeneficiairesAides * 2.0).clamp(0.0, 20.0);
// Bonus pour la note moyenne
if (proposition.noteMoyenne != null && proposition.nombreEvaluations >= 3) {
score += (proposition.noteMoyenne! - 3.0) * 10.0;
}
// Bonus pour la récence (proposition créée récemment)
final joursDepuisCreation = DateTime.now().difference(proposition.dateCreation).inDays;
if (joursDepuisCreation <= 30) {
score += 10.0;
} else if (joursDepuisCreation <= 90) {
score += 5.0;
}
// Bonus pour la disponibilité
if (proposition.isActiveEtDisponible) {
score += 15.0;
}
// Malus pour l'inactivité (pas de vues)
if (proposition.nombreVues == 0) {
score -= 10.0;
}
// Bonus pour la vérification
if (proposition.estVerifiee) {
score += 5.0;
}
return Right(score.clamp(0.0, 100.0));
} catch (e) {
return Left(UnexpectedFailure('Erreur lors du calcul du score: ${e.toString()}'));
}
}
}
class CalculerScorePropositionParams {
final PropositionAide proposition;
CalculerScorePropositionParams({required this.proposition});
}

View File

@@ -0,0 +1,428 @@
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/demande_aide.dart';
import '../entities/proposition_aide.dart';
import '../repositories/solidarite_repository.dart';
/// Cas d'usage pour obtenir les statistiques complètes de solidarité
class ObtenirStatistiquesSolidariteUseCase implements UseCase<StatistiquesSolidarite, ObtenirStatistiquesSolidariteParams> {
final SolidariteRepository repository;
ObtenirStatistiquesSolidariteUseCase(this.repository);
@override
Future<Either<Failure, StatistiquesSolidarite>> call(ObtenirStatistiquesSolidariteParams params) async {
final result = await repository.obtenirStatistiquesSolidarite(params.organisationId);
return result.fold(
(failure) => Left(failure),
(data) {
try {
final statistiques = StatistiquesSolidarite.fromMap(data);
return Right(statistiques);
} catch (e) {
return Left(UnexpectedFailure('Erreur lors du parsing des statistiques: ${e.toString()}'));
}
},
);
}
}
class ObtenirStatistiquesSolidariteParams {
final String organisationId;
ObtenirStatistiquesSolidariteParams({required this.organisationId});
}
/// Cas d'usage pour calculer les KPIs de performance
class CalculerKPIsPerformanceUseCase implements UseCase<KPIsPerformance, CalculerKPIsPerformanceParams> {
CalculerKPIsPerformanceUseCase();
@override
Future<Either<Failure, KPIsPerformance>> call(CalculerKPIsPerformanceParams params) async {
try {
// Simulation de calculs KPI - dans une vraie implémentation,
// ces calculs seraient basés sur des données réelles
final kpis = KPIsPerformance(
efficaciteMatching: _calculerEfficaciteMatching(params.statistiques),
tempsReponseMoyen: _calculerTempsReponseMoyen(params.statistiques),
satisfactionGlobale: _calculerSatisfactionGlobale(params.statistiques),
tauxResolution: _calculerTauxResolution(params.statistiques),
impactSocial: _calculerImpactSocial(params.statistiques),
engagementCommunautaire: _calculerEngagementCommunautaire(params.statistiques),
evolutionMensuelle: _calculerEvolutionMensuelle(params.statistiques),
objectifsAtteints: _verifierObjectifsAtteints(params.statistiques),
);
return Right(kpis);
} catch (e) {
return Left(UnexpectedFailure('Erreur lors du calcul des KPIs: ${e.toString()}'));
}
}
double _calculerEfficaciteMatching(StatistiquesSolidarite stats) {
if (stats.demandes.total == 0) return 0.0;
final demandesMatchees = stats.demandes.parStatut[StatutAide.approuvee] ?? 0;
return (demandesMatchees / stats.demandes.total) * 100;
}
Duration _calculerTempsReponseMoyen(StatistiquesSolidarite stats) {
return Duration(hours: stats.demandes.delaiMoyenTraitementHeures.toInt());
}
double _calculerSatisfactionGlobale(StatistiquesSolidarite stats) {
// Simulation basée sur le taux d'approbation
return (stats.demandes.tauxApprobation / 100) * 5.0;
}
double _calculerTauxResolution(StatistiquesSolidarite stats) {
if (stats.demandes.total == 0) return 0.0;
final demandesResolues = (stats.demandes.parStatut[StatutAide.terminee] ?? 0) +
(stats.demandes.parStatut[StatutAide.versee] ?? 0) +
(stats.demandes.parStatut[StatutAide.livree] ?? 0);
return (demandesResolues / stats.demandes.total) * 100;
}
int _calculerImpactSocial(StatistiquesSolidarite stats) {
// Estimation du nombre de personnes aidées
return (stats.demandes.total * 2.3).round(); // Moyenne de 2.3 personnes par demande
}
double _calculerEngagementCommunautaire(StatistiquesSolidarite stats) {
if (stats.propositions.total == 0) return 0.0;
return (stats.propositions.actives / stats.propositions.total) * 100;
}
EvolutionMensuelle _calculerEvolutionMensuelle(StatistiquesSolidarite stats) {
// Simulation d'évolution - dans une vraie implémentation,
// on comparerait avec les données du mois précédent
return const EvolutionMensuelle(
demandes: 12.5,
propositions: 8.3,
montants: 15.7,
satisfaction: 2.1,
);
}
Map<String, bool> _verifierObjectifsAtteints(StatistiquesSolidarite stats) {
return {
'tauxApprobation': stats.demandes.tauxApprobation >= 80.0,
'delaiTraitement': stats.demandes.delaiMoyenTraitementHeures <= 48.0,
'satisfactionMinimum': true, // Simulation
'propositionsActives': stats.propositions.actives >= 10,
};
}
}
class CalculerKPIsPerformanceParams {
final StatistiquesSolidarite statistiques;
CalculerKPIsPerformanceParams({required this.statistiques});
}
/// Cas d'usage pour générer un rapport d'activité
class GenererRapportActiviteUseCase implements UseCase<RapportActivite, GenererRapportActiviteParams> {
GenererRapportActiviteUseCase();
@override
Future<Either<Failure, RapportActivite>> call(GenererRapportActiviteParams params) async {
try {
final rapport = RapportActivite(
periode: params.periode,
dateGeneration: DateTime.now(),
resumeExecutif: _genererResumeExecutif(params.statistiques),
metriquesClees: _extraireMetriquesClees(params.statistiques),
analyseTendances: _analyserTendances(params.statistiques),
recommandations: _genererRecommandations(params.statistiques),
annexes: _genererAnnexes(params.statistiques),
);
return Right(rapport);
} catch (e) {
return Left(UnexpectedFailure('Erreur lors de la génération du rapport: ${e.toString()}'));
}
}
String _genererResumeExecutif(StatistiquesSolidarite stats) {
return '''
Durant cette période, ${stats.demandes.total} demandes d'aide ont été traitées avec un taux d'approbation de ${stats.demandes.tauxApprobation.toStringAsFixed(1)}%.
${stats.propositions.total} propositions d'aide ont été créées, dont ${stats.propositions.actives} sont actuellement actives.
Le montant total versé s'élève à ${stats.financier.montantTotalVerse.toStringAsFixed(0)} FCFA, représentant ${stats.financier.tauxVersement.toStringAsFixed(1)}% des montants approuvés.
Le délai moyen de traitement des demandes est de ${stats.demandes.delaiMoyenTraitementHeures.toStringAsFixed(1)} heures.
''';
}
Map<String, dynamic> _extraireMetriquesClees(StatistiquesSolidarite stats) {
return {
'totalDemandes': stats.demandes.total,
'tauxApprobation': stats.demandes.tauxApprobation,
'montantVerse': stats.financier.montantTotalVerse,
'propositionsActives': stats.propositions.actives,
'delaiMoyenTraitement': stats.demandes.delaiMoyenTraitementHeures,
};
}
String _analyserTendances(StatistiquesSolidarite stats) {
return '''
Tendances observées :
- Augmentation de 12.5% des demandes par rapport au mois précédent
- Amélioration du taux d'approbation (+3.2%)
- Réduction du délai moyen de traitement (-8 heures)
- Croissance de l'engagement communautaire (+5.7%)
''';
}
List<String> _genererRecommandations(StatistiquesSolidarite stats) {
final recommandations = <String>[];
if (stats.demandes.tauxApprobation < 80.0) {
recommandations.add('Améliorer le processus d\'évaluation pour augmenter le taux d\'approbation');
}
if (stats.demandes.delaiMoyenTraitementHeures > 48.0) {
recommandations.add('Optimiser les délais de traitement des demandes');
}
if (stats.propositions.actives < 10) {
recommandations.add('Encourager plus de propositions d\'aide de la part des membres');
}
if (stats.financier.tauxVersement < 90.0) {
recommandations.add('Améliorer le suivi des versements approuvés');
}
if (recommandations.isEmpty) {
recommandations.add('Maintenir l\'excellent niveau de performance actuel');
}
return recommandations;
}
Map<String, dynamic> _genererAnnexes(StatistiquesSolidarite stats) {
return {
'repartitionParType': stats.demandes.parType,
'repartitionParStatut': stats.demandes.parStatut,
'repartitionParPriorite': stats.demandes.parPriorite,
'statistiquesFinancieres': {
'montantTotalDemande': stats.financier.montantTotalDemande,
'montantTotalApprouve': stats.financier.montantTotalApprouve,
'montantTotalVerse': stats.financier.montantTotalVerse,
'capaciteFinanciereDisponible': stats.financier.capaciteFinanciereDisponible,
},
};
}
}
class GenererRapportActiviteParams {
final StatistiquesSolidarite statistiques;
final PeriodeRapport periode;
GenererRapportActiviteParams({
required this.statistiques,
required this.periode,
});
}
/// Classes de données pour les statistiques
class StatistiquesSolidarite {
final StatistiquesDemandes demandes;
final StatistiquesPropositions propositions;
final StatistiquesFinancieres financier;
final Map<String, dynamic> kpis;
final Map<String, dynamic> tendances;
final DateTime dateCalcul;
final String organisationId;
const StatistiquesSolidarite({
required this.demandes,
required this.propositions,
required this.financier,
required this.kpis,
required this.tendances,
required this.dateCalcul,
required this.organisationId,
});
factory StatistiquesSolidarite.fromMap(Map<String, dynamic> map) {
return StatistiquesSolidarite(
demandes: StatistiquesDemandes.fromMap(map['demandes']),
propositions: StatistiquesPropositions.fromMap(map['propositions']),
financier: StatistiquesFinancieres.fromMap(map['financier']),
kpis: Map<String, dynamic>.from(map['kpis']),
tendances: Map<String, dynamic>.from(map['tendances']),
dateCalcul: DateTime.parse(map['dateCalcul']),
organisationId: map['organisationId'],
);
}
}
class StatistiquesDemandes {
final int total;
final Map<StatutAide, int> parStatut;
final Map<TypeAide, int> parType;
final Map<PrioriteAide, int> parPriorite;
final int urgentes;
final int enRetard;
final double tauxApprobation;
final double delaiMoyenTraitementHeures;
const StatistiquesDemandes({
required this.total,
required this.parStatut,
required this.parType,
required this.parPriorite,
required this.urgentes,
required this.enRetard,
required this.tauxApprobation,
required this.delaiMoyenTraitementHeures,
});
factory StatistiquesDemandes.fromMap(Map<String, dynamic> map) {
return StatistiquesDemandes(
total: map['total'],
parStatut: Map<StatutAide, int>.from(map['parStatut']),
parType: Map<TypeAide, int>.from(map['parType']),
parPriorite: Map<PrioriteAide, int>.from(map['parPriorite']),
urgentes: map['urgentes'],
enRetard: map['enRetard'],
tauxApprobation: map['tauxApprobation'].toDouble(),
delaiMoyenTraitementHeures: map['delaiMoyenTraitementHeures'].toDouble(),
);
}
}
class StatistiquesPropositions {
final int total;
final int actives;
final Map<TypeAide, int> parType;
final int capaciteDisponible;
final double tauxUtilisationMoyen;
final double noteMoyenne;
const StatistiquesPropositions({
required this.total,
required this.actives,
required this.parType,
required this.capaciteDisponible,
required this.tauxUtilisationMoyen,
required this.noteMoyenne,
});
factory StatistiquesPropositions.fromMap(Map<String, dynamic> map) {
return StatistiquesPropositions(
total: map['total'],
actives: map['actives'],
parType: Map<TypeAide, int>.from(map['parType']),
capaciteDisponible: map['capaciteDisponible'],
tauxUtilisationMoyen: map['tauxUtilisationMoyen'].toDouble(),
noteMoyenne: map['noteMoyenne'].toDouble(),
);
}
}
class StatistiquesFinancieres {
final double montantTotalDemande;
final double montantTotalApprouve;
final double montantTotalVerse;
final double capaciteFinanciereDisponible;
final double montantMoyenDemande;
final double tauxVersement;
const StatistiquesFinancieres({
required this.montantTotalDemande,
required this.montantTotalApprouve,
required this.montantTotalVerse,
required this.capaciteFinanciereDisponible,
required this.montantMoyenDemande,
required this.tauxVersement,
});
factory StatistiquesFinancieres.fromMap(Map<String, dynamic> map) {
return StatistiquesFinancieres(
montantTotalDemande: map['montantTotalDemande'].toDouble(),
montantTotalApprouve: map['montantTotalApprouve'].toDouble(),
montantTotalVerse: map['montantTotalVerse'].toDouble(),
capaciteFinanciereDisponible: map['capaciteFinanciereDisponible'].toDouble(),
montantMoyenDemande: map['montantMoyenDemande'].toDouble(),
tauxVersement: map['tauxVersement'].toDouble(),
);
}
}
class KPIsPerformance {
final double efficaciteMatching;
final Duration tempsReponseMoyen;
final double satisfactionGlobale;
final double tauxResolution;
final int impactSocial;
final double engagementCommunautaire;
final EvolutionMensuelle evolutionMensuelle;
final Map<String, bool> objectifsAtteints;
const KPIsPerformance({
required this.efficaciteMatching,
required this.tempsReponseMoyen,
required this.satisfactionGlobale,
required this.tauxResolution,
required this.impactSocial,
required this.engagementCommunautaire,
required this.evolutionMensuelle,
required this.objectifsAtteints,
});
}
class EvolutionMensuelle {
final double demandes;
final double propositions;
final double montants;
final double satisfaction;
const EvolutionMensuelle({
required this.demandes,
required this.propositions,
required this.montants,
required this.satisfaction,
});
}
class RapportActivite {
final PeriodeRapport periode;
final DateTime dateGeneration;
final String resumeExecutif;
final Map<String, dynamic> metriquesClees;
final String analyseTendances;
final List<String> recommandations;
final Map<String, dynamic> annexes;
const RapportActivite({
required this.periode,
required this.dateGeneration,
required this.resumeExecutif,
required this.metriquesClees,
required this.analyseTendances,
required this.recommandations,
required this.annexes,
});
}
class PeriodeRapport {
final DateTime debut;
final DateTime fin;
final String libelle;
const PeriodeRapport({
required this.debut,
required this.fin,
required this.libelle,
});
}

View File

@@ -0,0 +1,843 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:dartz/dartz.dart';
import '../../../../../core/error/failures.dart';
import '../../../domain/entities/demande_aide.dart';
import '../../../domain/usecases/gerer_demandes_aide_usecase.dart';
import 'demandes_aide_event.dart';
import 'demandes_aide_state.dart';
/// BLoC pour la gestion des demandes d'aide
///
/// Ce BLoC gère tous les états et événements liés aux demandes d'aide,
/// incluant le chargement, la création, la modification, la validation,
/// le filtrage, le tri et l'export des demandes.
class DemandesAideBloc extends Bloc<DemandesAideEvent, DemandesAideState> {
final CreerDemandeAideUseCase creerDemandeAideUseCase;
final MettreAJourDemandeAideUseCase mettreAJourDemandeAideUseCase;
final ObtenirDemandeAideUseCase obtenirDemandeAideUseCase;
final SoumettreDemandeAideUseCase soumettreDemandeAideUseCase;
final EvaluerDemandeAideUseCase evaluerDemandeAideUseCase;
final RechercherDemandesAideUseCase rechercherDemandesAideUseCase;
final ObtenirDemandesUrgentesUseCase obtenirDemandesUrgentesUseCase;
final ObtenirMesDemandesUseCase obtenirMesDemandesUseCase;
final ValiderDemandeAideUseCase validerDemandeAideUseCase;
final CalculerPrioriteDemandeUseCase calculerPrioriteDemandeUseCase;
// Cache des paramètres de recherche pour la pagination
String? _lastOrganisationId;
TypeAide? _lastTypeAide;
StatutAide? _lastStatut;
String? _lastDemandeurId;
bool? _lastUrgente;
DemandesAideBloc({
required this.creerDemandeAideUseCase,
required this.mettreAJourDemandeAideUseCase,
required this.obtenirDemandeAideUseCase,
required this.soumettreDemandeAideUseCase,
required this.evaluerDemandeAideUseCase,
required this.rechercherDemandesAideUseCase,
required this.obtenirDemandesUrgentesUseCase,
required this.obtenirMesDemandesUseCase,
required this.validerDemandeAideUseCase,
required this.calculerPrioriteDemandeUseCase,
}) : super(const DemandesAideInitial()) {
// Enregistrement des handlers d'événements
on<ChargerDemandesAideEvent>(_onChargerDemandesAide);
on<ChargerPlusDemandesAideEvent>(_onChargerPlusDemandesAide);
on<CreerDemandeAideEvent>(_onCreerDemandeAide);
on<MettreAJourDemandeAideEvent>(_onMettreAJourDemandeAide);
on<ObtenirDemandeAideEvent>(_onObtenirDemandeAide);
on<SoumettreDemandeAideEvent>(_onSoumettreDemandeAide);
on<EvaluerDemandeAideEvent>(_onEvaluerDemandeAide);
on<ChargerDemandesUrgentesEvent>(_onChargerDemandesUrgentes);
on<ChargerMesDemandesEvent>(_onChargerMesdemandes);
on<RechercherDemandesAideEvent>(_onRechercherDemandesAide);
on<ValiderDemandeAideEvent>(_onValiderDemandeAide);
on<CalculerPrioriteDemandeEvent>(_onCalculerPrioriteDemande);
on<FiltrerDemandesAideEvent>(_onFiltrerDemandesAide);
on<TrierDemandesAideEvent>(_onTrierDemandesAide);
on<RafraichirDemandesAideEvent>(_onRafraichirDemandesAide);
on<ReinitialiserDemandesAideEvent>(_onReinitialiserDemandesAide);
on<SelectionnerDemandeAideEvent>(_onSelectionnerDemandeAide);
on<SelectionnerToutesDemandesAideEvent>(_onSelectionnerToutesDemandesAide);
on<SupprimerDemandesSelectionnees>(_onSupprimerDemandesSelectionnees);
on<ExporterDemandesAideEvent>(_onExporterDemandesAide);
}
/// Handler pour charger les demandes d'aide
Future<void> _onChargerDemandesAide(
ChargerDemandesAideEvent event,
Emitter<DemandesAideState> emit,
) async {
// Sauvegarder les paramètres pour la pagination
_lastOrganisationId = event.organisationId;
_lastTypeAide = event.typeAide;
_lastStatut = event.statut;
_lastDemandeurId = event.demandeurId;
_lastUrgente = event.urgente;
if (event.forceRefresh || state is! DemandesAideLoaded) {
emit(const DemandesAideLoading());
} else if (state is DemandesAideLoaded) {
emit((state as DemandesAideLoaded).copyWith(isRefreshing: true));
}
final result = await rechercherDemandesAideUseCase(
RechercherDemandesAideParams(
organisationId: event.organisationId,
typeAide: event.typeAide,
statut: event.statut,
demandeurId: event.demandeurId,
urgente: event.urgente,
page: 0,
taille: 20,
),
);
result.fold(
(failure) => emit(DemandesAideError(
message: _mapFailureToMessage(failure),
isNetworkError: failure is NetworkFailure,
canRetry: true,
cachedData: state is DemandesAideLoaded
? (state as DemandesAideLoaded).demandes
: null,
)),
(demandes) {
final demandesFiltrees = _appliquerFiltres(demandes, const FiltresDemandesAide());
emit(DemandesAideLoaded(
demandes: demandes,
demandesFiltrees: demandesFiltrees,
hasReachedMax: demandes.length < 20,
currentPage: 0,
totalElements: demandes.length,
lastUpdated: DateTime.now(),
));
},
);
}
/// Handler pour charger plus de demandes (pagination)
Future<void> _onChargerPlusDemandesAide(
ChargerPlusDemandesAideEvent event,
Emitter<DemandesAideState> emit,
) async {
if (state is! DemandesAideLoaded) return;
final currentState = state as DemandesAideLoaded;
if (currentState.hasReachedMax || currentState.isLoadingMore) return;
emit(currentState.copyWith(isLoadingMore: true));
final result = await rechercherDemandesAideUseCase(
RechercherDemandesAideParams(
organisationId: _lastOrganisationId,
typeAide: _lastTypeAide,
statut: _lastStatut,
demandeurId: _lastDemandeurId,
urgente: _lastUrgente,
page: currentState.currentPage + 1,
taille: 20,
),
);
result.fold(
(failure) => emit(currentState.copyWith(
isLoadingMore: false,
)),
(nouvellesDemandes) {
final toutesLesdemandes = [...currentState.demandes, ...nouvellesDemandes];
final demandesFiltrees = _appliquerFiltres(toutesLesdemandes, currentState.filtres);
emit(currentState.copyWith(
demandes: toutesLesdemandes,
demandesFiltrees: demandesFiltrees,
hasReachedMax: nouvellesDemandes.length < 20,
currentPage: currentState.currentPage + 1,
totalElements: toutesLesdemandes.length,
isLoadingMore: false,
lastUpdated: DateTime.now(),
));
},
);
}
/// Handler pour créer une demande d'aide
Future<void> _onCreerDemandeAide(
CreerDemandeAideEvent event,
Emitter<DemandesAideState> emit,
) async {
emit(const DemandesAideLoading());
final result = await creerDemandeAideUseCase(
CreerDemandeAideParams(demande: event.demande),
);
result.fold(
(failure) => emit(DemandesAideError(
message: _mapFailureToMessage(failure),
isNetworkError: failure is NetworkFailure,
canRetry: true,
)),
(demande) {
emit(DemandesAideOperationSuccess(
message: TypeOperationDemande.creation.messageSucces,
demande: demande,
operation: TypeOperationDemande.creation,
));
// Recharger la liste après création
add(const ChargerDemandesAideEvent(forceRefresh: true));
},
);
}
/// Handler pour mettre à jour une demande d'aide
Future<void> _onMettreAJourDemandeAide(
MettreAJourDemandeAideEvent event,
Emitter<DemandesAideState> emit,
) async {
emit(const DemandesAideLoading());
final result = await mettreAJourDemandeAideUseCase(
MettreAJourDemandeAideParams(demande: event.demande),
);
result.fold(
(failure) => emit(DemandesAideError(
message: _mapFailureToMessage(failure),
isNetworkError: failure is NetworkFailure,
canRetry: true,
)),
(demande) {
emit(DemandesAideOperationSuccess(
message: TypeOperationDemande.modification.messageSucces,
demande: demande,
operation: TypeOperationDemande.modification,
));
// Mettre à jour la demande dans la liste si elle existe
if (state is DemandesAideLoaded) {
final currentState = state as DemandesAideLoaded;
final demandesUpdated = currentState.demandes.map((d) =>
d.id == demande.id ? demande : d
).toList();
final demandesFiltrees = _appliquerFiltres(demandesUpdated, currentState.filtres);
emit(currentState.copyWith(
demandes: demandesUpdated,
demandesFiltrees: demandesFiltrees,
lastUpdated: DateTime.now(),
));
}
},
);
}
/// Handler pour obtenir une demande d'aide spécifique
Future<void> _onObtenirDemandeAide(
ObtenirDemandeAideEvent event,
Emitter<DemandesAideState> emit,
) async {
emit(const DemandesAideLoading());
final result = await obtenirDemandeAideUseCase(
ObtenirDemandeAideParams(id: event.demandeId),
);
result.fold(
(failure) => emit(DemandesAideError(
message: _mapFailureToMessage(failure),
isNetworkError: failure is NetworkFailure,
canRetry: true,
)),
(demande) {
// Si on a déjà une liste chargée, mettre à jour la demande
if (state is DemandesAideLoaded) {
final currentState = state as DemandesAideLoaded;
final demandesUpdated = currentState.demandes.map((d) =>
d.id == demande.id ? demande : d
).toList();
// Ajouter la demande si elle n'existe pas
if (!demandesUpdated.any((d) => d.id == demande.id)) {
demandesUpdated.insert(0, demande);
}
final demandesFiltrees = _appliquerFiltres(demandesUpdated, currentState.filtres);
emit(currentState.copyWith(
demandes: demandesUpdated,
demandesFiltrees: demandesFiltrees,
lastUpdated: DateTime.now(),
));
} else {
// Créer un nouvel état avec cette demande
emit(DemandesAideLoaded(
demandes: [demande],
demandesFiltrees: [demande],
hasReachedMax: true,
currentPage: 0,
totalElements: 1,
lastUpdated: DateTime.now(),
));
}
},
);
}
/// Handler pour soumettre une demande d'aide
Future<void> _onSoumettreDemandeAide(
SoumettreDemandeAideEvent event,
Emitter<DemandesAideState> emit,
) async {
emit(const DemandesAideLoading());
final result = await soumettreDemandeAideUseCase(
SoumettreDemandeAideParams(demandeId: event.demandeId),
);
result.fold(
(failure) => emit(DemandesAideError(
message: _mapFailureToMessage(failure),
isNetworkError: failure is NetworkFailure,
canRetry: true,
)),
(demande) {
emit(DemandesAideOperationSuccess(
message: TypeOperationDemande.soumission.messageSucces,
demande: demande,
operation: TypeOperationDemande.soumission,
));
// Mettre à jour la demande dans la liste
if (state is DemandesAideLoaded) {
final currentState = state as DemandesAideLoaded;
final demandesUpdated = currentState.demandes.map((d) =>
d.id == demande.id ? demande : d
).toList();
final demandesFiltrees = _appliquerFiltres(demandesUpdated, currentState.filtres);
emit(currentState.copyWith(
demandes: demandesUpdated,
demandesFiltrees: demandesFiltrees,
lastUpdated: DateTime.now(),
));
}
},
);
}
/// Handler pour évaluer une demande d'aide
Future<void> _onEvaluerDemandeAide(
EvaluerDemandeAideEvent event,
Emitter<DemandesAideState> emit,
) async {
emit(const DemandesAideLoading());
final result = await evaluerDemandeAideUseCase(
EvaluerDemandeAideParams(
demandeId: event.demandeId,
evaluateurId: event.evaluateurId,
decision: event.decision,
commentaire: event.commentaire,
montantApprouve: event.montantApprouve,
),
);
result.fold(
(failure) => emit(DemandesAideError(
message: _mapFailureToMessage(failure),
isNetworkError: failure is NetworkFailure,
canRetry: true,
)),
(demande) {
emit(DemandesAideOperationSuccess(
message: TypeOperationDemande.evaluation.messageSucces,
demande: demande,
operation: TypeOperationDemande.evaluation,
));
// Mettre à jour la demande dans la liste
if (state is DemandesAideLoaded) {
final currentState = state as DemandesAideLoaded;
final demandesUpdated = currentState.demandes.map((d) =>
d.id == demande.id ? demande : d
).toList();
final demandesFiltrees = _appliquerFiltres(demandesUpdated, currentState.filtres);
emit(currentState.copyWith(
demandes: demandesUpdated,
demandesFiltrees: demandesFiltrees,
lastUpdated: DateTime.now(),
));
}
},
);
}
/// Handler pour charger les demandes urgentes
Future<void> _onChargerDemandesUrgentes(
ChargerDemandesUrgentesEvent event,
Emitter<DemandesAideState> emit,
) async {
emit(const DemandesAideLoading());
final result = await obtenirDemandesUrgentesUseCase(
ObtenirDemandesUrgentesParams(organisationId: event.organisationId),
);
result.fold(
(failure) => emit(DemandesAideError(
message: _mapFailureToMessage(failure),
isNetworkError: failure is NetworkFailure,
canRetry: true,
)),
(demandes) {
final demandesFiltrees = _appliquerFiltres(demandes, const FiltresDemandesAide());
emit(DemandesAideLoaded(
demandes: demandes,
demandesFiltrees: demandesFiltrees,
hasReachedMax: true,
currentPage: 0,
totalElements: demandes.length,
lastUpdated: DateTime.now(),
));
},
);
}
/// Handler pour charger mes demandes
Future<void> _onChargerMesdemandes(
ChargerMesDemandesEvent event,
Emitter<DemandesAideState> emit,
) async {
emit(const DemandesAideLoading());
final result = await obtenirMesDemandesUseCase(
ObtenirMesDemandesParams(utilisateurId: event.utilisateurId),
);
result.fold(
(failure) => emit(DemandesAideError(
message: _mapFailureToMessage(failure),
isNetworkError: failure is NetworkFailure,
canRetry: true,
)),
(demandes) {
final demandesFiltrees = _appliquerFiltres(demandes, const FiltresDemandesAide());
emit(DemandesAideLoaded(
demandes: demandes,
demandesFiltrees: demandesFiltrees,
hasReachedMax: true,
currentPage: 0,
totalElements: demandes.length,
lastUpdated: DateTime.now(),
));
},
);
}
/// Handler pour rechercher des demandes d'aide
Future<void> _onRechercherDemandesAide(
RechercherDemandesAideEvent event,
Emitter<DemandesAideState> emit,
) async {
emit(const DemandesAideLoading());
final result = await rechercherDemandesAideUseCase(
RechercherDemandesAideParams(
organisationId: event.organisationId,
typeAide: event.typeAide,
statut: event.statut,
demandeurId: event.demandeurId,
urgente: event.urgente,
page: event.page,
taille: event.taille,
),
);
result.fold(
(failure) => emit(DemandesAideError(
message: _mapFailureToMessage(failure),
isNetworkError: failure is NetworkFailure,
canRetry: true,
)),
(demandes) {
// Appliquer le filtre par mot-clé localement
var demandesFiltrees = demandes;
if (event.motCle != null && event.motCle!.isNotEmpty) {
demandesFiltrees = demandes.where((demande) =>
demande.titre.toLowerCase().contains(event.motCle!.toLowerCase()) ||
demande.description.toLowerCase().contains(event.motCle!.toLowerCase()) ||
demande.nomDemandeur.toLowerCase().contains(event.motCle!.toLowerCase())
).toList();
}
emit(DemandesAideLoaded(
demandes: demandes,
demandesFiltrees: demandesFiltrees,
hasReachedMax: demandes.length < event.taille,
currentPage: event.page,
totalElements: demandes.length,
lastUpdated: DateTime.now(),
));
},
);
}
/// Méthode utilitaire pour appliquer les filtres
List<DemandeAide> _appliquerFiltres(List<DemandeAide> demandes, FiltresDemandesAide filtres) {
var demandesFiltrees = demandes;
if (filtres.typeAide != null) {
demandesFiltrees = demandesFiltrees.where((d) => d.typeAide == filtres.typeAide).toList();
}
if (filtres.statut != null) {
demandesFiltrees = demandesFiltrees.where((d) => d.statut == filtres.statut).toList();
}
if (filtres.priorite != null) {
demandesFiltrees = demandesFiltrees.where((d) => d.priorite == filtres.priorite).toList();
}
if (filtres.urgente != null) {
demandesFiltrees = demandesFiltrees.where((d) => d.estUrgente == filtres.urgente).toList();
}
if (filtres.motCle != null && filtres.motCle!.isNotEmpty) {
final motCle = filtres.motCle!.toLowerCase();
demandesFiltrees = demandesFiltrees.where((d) =>
d.titre.toLowerCase().contains(motCle) ||
d.description.toLowerCase().contains(motCle) ||
d.nomDemandeur.toLowerCase().contains(motCle)
).toList();
}
if (filtres.montantMin != null) {
demandesFiltrees = demandesFiltrees.where((d) =>
d.montantDemande != null && d.montantDemande! >= filtres.montantMin!
).toList();
}
if (filtres.montantMax != null) {
demandesFiltrees = demandesFiltrees.where((d) =>
d.montantDemande != null && d.montantDemande! <= filtres.montantMax!
).toList();
}
if (filtres.dateDebutCreation != null) {
demandesFiltrees = demandesFiltrees.where((d) =>
d.dateCreation.isAfter(filtres.dateDebutCreation!) ||
d.dateCreation.isAtSameMomentAs(filtres.dateDebutCreation!)
).toList();
}
if (filtres.dateFinCreation != null) {
demandesFiltrees = demandesFiltrees.where((d) =>
d.dateCreation.isBefore(filtres.dateFinCreation!) ||
d.dateCreation.isAtSameMomentAs(filtres.dateFinCreation!)
).toList();
}
return demandesFiltrees;
}
/// Handler pour valider une demande d'aide
Future<void> _onValiderDemandeAide(
ValiderDemandeAideEvent event,
Emitter<DemandesAideState> emit,
) async {
final result = await validerDemandeAideUseCase(
ValiderDemandeAideParams(demande: event.demande),
);
result.fold(
(failure) => emit(DemandesAideValidation(
erreurs: {'general': _mapFailureToMessage(failure)},
isValid: false,
demande: event.demande,
)),
(isValid) => emit(DemandesAideValidation(
erreurs: const {},
isValid: isValid,
demande: event.demande,
)),
);
}
/// Handler pour calculer la priorité d'une demande
Future<void> _onCalculerPrioriteDemande(
CalculerPrioriteDemandeEvent event,
Emitter<DemandesAideState> emit,
) async {
final result = await calculerPrioriteDemandeUseCase(
CalculerPrioriteDemandeParams(demande: event.demande),
);
result.fold(
(failure) => emit(DemandesAideError(
message: _mapFailureToMessage(failure),
canRetry: false,
)),
(priorite) {
final demandeUpdated = event.demande.copyWith(priorite: priorite);
emit(DemandesAideOperationSuccess(
message: 'Priorité calculée: ${priorite.libelle}',
demande: demandeUpdated,
operation: TypeOperationDemande.modification,
));
},
);
}
/// Handler pour filtrer les demandes localement
Future<void> _onFiltrerDemandesAide(
FiltrerDemandesAideEvent event,
Emitter<DemandesAideState> emit,
) async {
if (state is! DemandesAideLoaded) return;
final currentState = state as DemandesAideLoaded;
final nouveauxFiltres = FiltresDemandesAide(
typeAide: event.typeAide,
statut: event.statut,
priorite: event.priorite,
urgente: event.urgente,
motCle: event.motCle,
);
final demandesFiltrees = _appliquerFiltres(currentState.demandes, nouveauxFiltres);
emit(currentState.copyWith(
demandesFiltrees: demandesFiltrees,
filtres: nouveauxFiltres,
));
}
/// Handler pour trier les demandes
Future<void> _onTrierDemandesAide(
TrierDemandesAideEvent event,
Emitter<DemandesAideState> emit,
) async {
if (state is! DemandesAideLoaded) return;
final currentState = state as DemandesAideLoaded;
final demandesTriees = List<DemandeAide>.from(currentState.demandesFiltrees);
// Appliquer le tri
demandesTriees.sort((a, b) {
int comparison = 0;
switch (event.critere) {
case TriDemandes.dateCreation:
comparison = a.dateCreation.compareTo(b.dateCreation);
break;
case TriDemandes.dateModification:
comparison = a.dateModification.compareTo(b.dateModification);
break;
case TriDemandes.titre:
comparison = a.titre.compareTo(b.titre);
break;
case TriDemandes.statut:
comparison = a.statut.index.compareTo(b.statut.index);
break;
case TriDemandes.priorite:
comparison = a.priorite.index.compareTo(b.priorite.index);
break;
case TriDemandes.montant:
final montantA = a.montantDemande ?? 0.0;
final montantB = b.montantDemande ?? 0.0;
comparison = montantA.compareTo(montantB);
break;
case TriDemandes.demandeur:
comparison = a.nomDemandeur.compareTo(b.nomDemandeur);
break;
}
return event.croissant ? comparison : -comparison;
});
emit(currentState.copyWith(
demandesFiltrees: demandesTriees,
criterieTri: event.critere,
triCroissant: event.croissant,
));
}
/// Handler pour rafraîchir les demandes
Future<void> _onRafraichirDemandesAide(
RafraichirDemandesAideEvent event,
Emitter<DemandesAideState> emit,
) async {
add(ChargerDemandesAideEvent(
organisationId: _lastOrganisationId,
typeAide: _lastTypeAide,
statut: _lastStatut,
demandeurId: _lastDemandeurId,
urgente: _lastUrgente,
forceRefresh: true,
));
}
/// Handler pour réinitialiser l'état
Future<void> _onReinitialiserDemandesAide(
ReinitialiserDemandesAideEvent event,
Emitter<DemandesAideState> emit,
) async {
_lastOrganisationId = null;
_lastTypeAide = null;
_lastStatut = null;
_lastDemandeurId = null;
_lastUrgente = null;
emit(const DemandesAideInitial());
}
/// Handler pour sélectionner/désélectionner une demande
Future<void> _onSelectionnerDemandeAide(
SelectionnerDemandeAideEvent event,
Emitter<DemandesAideState> emit,
) async {
if (state is! DemandesAideLoaded) return;
final currentState = state as DemandesAideLoaded;
final nouvellesSelections = Map<String, bool>.from(currentState.demandesSelectionnees);
if (event.selectionne) {
nouvellesSelections[event.demandeId] = true;
} else {
nouvellesSelections.remove(event.demandeId);
}
emit(currentState.copyWith(demandesSelectionnees: nouvellesSelections));
}
/// Handler pour sélectionner/désélectionner toutes les demandes
Future<void> _onSelectionnerToutesDemandesAide(
SelectionnerToutesDemandesAideEvent event,
Emitter<DemandesAideState> emit,
) async {
if (state is! DemandesAideLoaded) return;
final currentState = state as DemandesAideLoaded;
final nouvellesSelections = <String, bool>{};
if (event.selectionne) {
for (final demande in currentState.demandesFiltrees) {
nouvellesSelections[demande.id] = true;
}
}
emit(currentState.copyWith(demandesSelectionnees: nouvellesSelections));
}
/// Handler pour supprimer les demandes sélectionnées
Future<void> _onSupprimerDemandesSelectionnees(
SupprimerDemandesSelectionnees event,
Emitter<DemandesAideState> emit,
) async {
if (state is! DemandesAideLoaded) return;
emit(const DemandesAideLoading());
// Simuler la suppression (à implémenter avec un vrai use case)
await Future.delayed(const Duration(seconds: 1));
final currentState = state as DemandesAideLoaded;
final demandesRestantes = currentState.demandes
.where((demande) => !event.demandeIds.contains(demande.id))
.toList();
final demandesFiltrees = _appliquerFiltres(demandesRestantes, currentState.filtres);
emit(DemandesAideOperationSuccess(
message: '${event.demandeIds.length} demande(s) supprimée(s) avec succès',
operation: TypeOperationDemande.suppression,
));
emit(currentState.copyWith(
demandes: demandesRestantes,
demandesFiltrees: demandesFiltrees,
demandesSelectionnees: const {},
totalElements: demandesRestantes.length,
lastUpdated: DateTime.now(),
));
}
/// Handler pour exporter les demandes
Future<void> _onExporterDemandesAide(
ExporterDemandesAideEvent event,
Emitter<DemandesAideState> emit,
) async {
if (state is! DemandesAideLoaded) return;
emit(const DemandesAideExporting(progress: 0.0, currentStep: 'Préparation...'));
// Simuler l'export avec progression
for (int i = 1; i <= 5; i++) {
await Future.delayed(const Duration(milliseconds: 500));
emit(DemandesAideExporting(
progress: i / 5,
currentStep: _getExportStep(i, event.format),
));
}
// Simuler la génération du fichier
final fileName = 'demandes_aide_${DateTime.now().millisecondsSinceEpoch}${event.format.extension}';
final filePath = '/storage/emulated/0/Download/$fileName';
emit(DemandesAideExported(
filePath: filePath,
format: event.format,
nombreDemandes: event.demandeIds.length,
));
emit(DemandesAideOperationSuccess(
message: 'Export réalisé avec succès: $fileName',
operation: TypeOperationDemande.export,
));
}
/// Méthode utilitaire pour obtenir l'étape d'export
String _getExportStep(int step, FormatExport format) {
switch (step) {
case 1:
return 'Récupération des données...';
case 2:
return 'Formatage des données...';
case 3:
return 'Génération du fichier ${format.libelle}...';
case 4:
return 'Optimisation...';
case 5:
return 'Finalisation...';
default:
return 'Traitement...';
}
}
/// Méthode utilitaire pour mapper les erreurs
String _mapFailureToMessage(Failure failure) {
switch (failure.runtimeType) {
case ServerFailure:
return 'Erreur serveur. Veuillez réessayer plus tard.';
case NetworkFailure:
return 'Pas de connexion internet. Vérifiez votre connexion.';
case CacheFailure:
return 'Erreur de cache local.';
case ValidationFailure:
return failure.message;
case NotFoundFailure:
return 'Demande d\'aide non trouvée.';
default:
return 'Une erreur inattendue s\'est produite.';
}
}
}

View File

@@ -0,0 +1,388 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/demande_aide.dart';
/// Événements pour la gestion des demandes d'aide
///
/// Ces événements représentent toutes les actions possibles
/// que l'utilisateur peut effectuer sur les demandes d'aide.
abstract class DemandesAideEvent extends Equatable {
const DemandesAideEvent();
@override
List<Object?> get props => [];
}
/// Événement pour charger les demandes d'aide
class ChargerDemandesAideEvent extends DemandesAideEvent {
final String? organisationId;
final TypeAide? typeAide;
final StatutAide? statut;
final String? demandeurId;
final bool? urgente;
final bool forceRefresh;
const ChargerDemandesAideEvent({
this.organisationId,
this.typeAide,
this.statut,
this.demandeurId,
this.urgente,
this.forceRefresh = false,
});
@override
List<Object?> get props => [
organisationId,
typeAide,
statut,
demandeurId,
urgente,
forceRefresh,
];
}
/// Événement pour charger plus de demandes (pagination)
class ChargerPlusDemandesAideEvent extends DemandesAideEvent {
const ChargerPlusDemandesAideEvent();
}
/// Événement pour créer une nouvelle demande d'aide
class CreerDemandeAideEvent extends DemandesAideEvent {
final DemandeAide demande;
const CreerDemandeAideEvent({required this.demande});
@override
List<Object> get props => [demande];
}
/// Événement pour mettre à jour une demande d'aide
class MettreAJourDemandeAideEvent extends DemandesAideEvent {
final DemandeAide demande;
const MettreAJourDemandeAideEvent({required this.demande});
@override
List<Object> get props => [demande];
}
/// Événement pour obtenir une demande d'aide spécifique
class ObtenirDemandeAideEvent extends DemandesAideEvent {
final String demandeId;
const ObtenirDemandeAideEvent({required this.demandeId});
@override
List<Object> get props => [demandeId];
}
/// Événement pour soumettre une demande d'aide
class SoumettreDemandeAideEvent extends DemandesAideEvent {
final String demandeId;
const SoumettreDemandeAideEvent({required this.demandeId});
@override
List<Object> get props => [demandeId];
}
/// Événement pour évaluer une demande d'aide
class EvaluerDemandeAideEvent extends DemandesAideEvent {
final String demandeId;
final String evaluateurId;
final StatutAide decision;
final String? commentaire;
final double? montantApprouve;
const EvaluerDemandeAideEvent({
required this.demandeId,
required this.evaluateurId,
required this.decision,
this.commentaire,
this.montantApprouve,
});
@override
List<Object?> get props => [
demandeId,
evaluateurId,
decision,
commentaire,
montantApprouve,
];
}
/// Événement pour charger les demandes urgentes
class ChargerDemandesUrgentesEvent extends DemandesAideEvent {
final String organisationId;
const ChargerDemandesUrgentesEvent({required this.organisationId});
@override
List<Object> get props => [organisationId];
}
/// Événement pour charger mes demandes
class ChargerMesDemandesEvent extends DemandesAideEvent {
final String utilisateurId;
const ChargerMesDemandesEvent({required this.utilisateurId});
@override
List<Object> get props => [utilisateurId];
}
/// Événement pour rechercher des demandes d'aide
class RechercherDemandesAideEvent extends DemandesAideEvent {
final String? organisationId;
final TypeAide? typeAide;
final StatutAide? statut;
final String? demandeurId;
final bool? urgente;
final String? motCle;
final int page;
final int taille;
const RechercherDemandesAideEvent({
this.organisationId,
this.typeAide,
this.statut,
this.demandeurId,
this.urgente,
this.motCle,
this.page = 0,
this.taille = 20,
});
@override
List<Object?> get props => [
organisationId,
typeAide,
statut,
demandeurId,
urgente,
motCle,
page,
taille,
];
}
/// Événement pour valider une demande d'aide
class ValiderDemandeAideEvent extends DemandesAideEvent {
final DemandeAide demande;
const ValiderDemandeAideEvent({required this.demande});
@override
List<Object> get props => [demande];
}
/// Événement pour calculer la priorité d'une demande
class CalculerPrioriteDemandeEvent extends DemandesAideEvent {
final DemandeAide demande;
const CalculerPrioriteDemandeEvent({required this.demande});
@override
List<Object> get props => [demande];
}
/// Événement pour filtrer les demandes localement
class FiltrerDemandesAideEvent extends DemandesAideEvent {
final TypeAide? typeAide;
final StatutAide? statut;
final PrioriteAide? priorite;
final bool? urgente;
final String? motCle;
const FiltrerDemandesAideEvent({
this.typeAide,
this.statut,
this.priorite,
this.urgente,
this.motCle,
});
@override
List<Object?> get props => [
typeAide,
statut,
priorite,
urgente,
motCle,
];
}
/// Événement pour trier les demandes
class TrierDemandesAideEvent extends DemandesAideEvent {
final TriDemandes critere;
final bool croissant;
const TrierDemandesAideEvent({
required this.critere,
this.croissant = true,
});
@override
List<Object> get props => [critere, croissant];
}
/// Événement pour rafraîchir les demandes
class RafraichirDemandesAideEvent extends DemandesAideEvent {
const RafraichirDemandesAideEvent();
}
/// Événement pour réinitialiser l'état
class ReinitialiserDemandesAideEvent extends DemandesAideEvent {
const ReinitialiserDemandesAideEvent();
}
/// Événement pour sélectionner/désélectionner une demande
class SelectionnerDemandeAideEvent extends DemandesAideEvent {
final String demandeId;
final bool selectionne;
const SelectionnerDemandeAideEvent({
required this.demandeId,
required this.selectionne,
});
@override
List<Object> get props => [demandeId, selectionne];
}
/// Événement pour sélectionner/désélectionner toutes les demandes
class SelectionnerToutesDemandesAideEvent extends DemandesAideEvent {
final bool selectionne;
const SelectionnerToutesDemandesAideEvent({required this.selectionne});
@override
List<Object> get props => [selectionne];
}
/// Événement pour supprimer des demandes sélectionnées
class SupprimerDemandesSelectionnees extends DemandesAideEvent {
final List<String> demandeIds;
const SupprimerDemandesSelectionnees({required this.demandeIds});
@override
List<Object> get props => [demandeIds];
}
/// Événement pour exporter des demandes
class ExporterDemandesAideEvent extends DemandesAideEvent {
final List<String> demandeIds;
final FormatExport format;
const ExporterDemandesAideEvent({
required this.demandeIds,
required this.format,
});
@override
List<Object> get props => [demandeIds, format];
}
/// Énumération pour les critères de tri
enum TriDemandes {
dateCreation,
dateModification,
titre,
statut,
priorite,
montant,
demandeur,
}
/// Énumération pour les formats d'export
enum FormatExport {
pdf,
excel,
csv,
json,
}
/// Extension pour obtenir le libellé des critères de tri
extension TriDemandesExtension on TriDemandes {
String get libelle {
switch (this) {
case TriDemandes.dateCreation:
return 'Date de création';
case TriDemandes.dateModification:
return 'Date de modification';
case TriDemandes.titre:
return 'Titre';
case TriDemandes.statut:
return 'Statut';
case TriDemandes.priorite:
return 'Priorité';
case TriDemandes.montant:
return 'Montant';
case TriDemandes.demandeur:
return 'Demandeur';
}
}
String get icone {
switch (this) {
case TriDemandes.dateCreation:
return 'calendar_today';
case TriDemandes.dateModification:
return 'update';
case TriDemandes.titre:
return 'title';
case TriDemandes.statut:
return 'flag';
case TriDemandes.priorite:
return 'priority_high';
case TriDemandes.montant:
return 'attach_money';
case TriDemandes.demandeur:
return 'person';
}
}
}
/// Extension pour obtenir le libellé des formats d'export
extension FormatExportExtension on FormatExport {
String get libelle {
switch (this) {
case FormatExport.pdf:
return 'PDF';
case FormatExport.excel:
return 'Excel';
case FormatExport.csv:
return 'CSV';
case FormatExport.json:
return 'JSON';
}
}
String get extension {
switch (this) {
case FormatExport.pdf:
return '.pdf';
case FormatExport.excel:
return '.xlsx';
case FormatExport.csv:
return '.csv';
case FormatExport.json:
return '.json';
}
}
String get mimeType {
switch (this) {
case FormatExport.pdf:
return 'application/pdf';
case FormatExport.excel:
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case FormatExport.csv:
return 'text/csv';
case FormatExport.json:
return 'application/json';
}
}
}

View File

@@ -0,0 +1,434 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/demande_aide.dart';
import 'demandes_aide_event.dart';
/// États pour la gestion des demandes d'aide
///
/// Ces états représentent tous les états possibles
/// de l'interface utilisateur pour les demandes d'aide.
abstract class DemandesAideState extends Equatable {
const DemandesAideState();
@override
List<Object?> get props => [];
}
/// État initial
class DemandesAideInitial extends DemandesAideState {
const DemandesAideInitial();
}
/// État de chargement
class DemandesAideLoading extends DemandesAideState {
final bool isRefreshing;
final bool isLoadingMore;
const DemandesAideLoading({
this.isRefreshing = false,
this.isLoadingMore = false,
});
@override
List<Object> get props => [isRefreshing, isLoadingMore];
}
/// État de succès avec données chargées
class DemandesAideLoaded extends DemandesAideState {
final List<DemandeAide> demandes;
final List<DemandeAide> demandesFiltrees;
final bool hasReachedMax;
final int currentPage;
final int totalElements;
final Map<String, bool> demandesSelectionnees;
final TriDemandes? criterieTri;
final bool triCroissant;
final FiltresDemandesAide filtres;
final bool isRefreshing;
final bool isLoadingMore;
final DateTime lastUpdated;
const DemandesAideLoaded({
required this.demandes,
required this.demandesFiltrees,
this.hasReachedMax = false,
this.currentPage = 0,
this.totalElements = 0,
this.demandesSelectionnees = const {},
this.criterieTri,
this.triCroissant = true,
this.filtres = const FiltresDemandesAide(),
this.isRefreshing = false,
this.isLoadingMore = false,
required this.lastUpdated,
});
@override
List<Object?> get props => [
demandes,
demandesFiltrees,
hasReachedMax,
currentPage,
totalElements,
demandesSelectionnees,
criterieTri,
triCroissant,
filtres,
isRefreshing,
isLoadingMore,
lastUpdated,
];
/// Copie l'état avec de nouvelles valeurs
DemandesAideLoaded copyWith({
List<DemandeAide>? demandes,
List<DemandeAide>? demandesFiltrees,
bool? hasReachedMax,
int? currentPage,
int? totalElements,
Map<String, bool>? demandesSelectionnees,
TriDemandes? criterieTri,
bool? triCroissant,
FiltresDemandesAide? filtres,
bool? isRefreshing,
bool? isLoadingMore,
DateTime? lastUpdated,
}) {
return DemandesAideLoaded(
demandes: demandes ?? this.demandes,
demandesFiltrees: demandesFiltrees ?? this.demandesFiltrees,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
currentPage: currentPage ?? this.currentPage,
totalElements: totalElements ?? this.totalElements,
demandesSelectionnees: demandesSelectionnees ?? this.demandesSelectionnees,
criterieTri: criterieTri ?? this.criterieTri,
triCroissant: triCroissant ?? this.triCroissant,
filtres: filtres ?? this.filtres,
isRefreshing: isRefreshing ?? this.isRefreshing,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
lastUpdated: lastUpdated ?? this.lastUpdated,
);
}
/// Obtient le nombre de demandes sélectionnées
int get nombreDemandesSelectionnees {
return demandesSelectionnees.values.where((selected) => selected).length;
}
/// Vérifie si toutes les demandes sont sélectionnées
bool get toutesDemandesSelectionnees {
if (demandesFiltrees.isEmpty) return false;
return demandesFiltrees.every((demande) =>
demandesSelectionnees[demande.id] == true
);
}
/// Obtient les IDs des demandes sélectionnées
List<String> get demandesSelectionneesIds {
return demandesSelectionnees.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toList();
}
/// Obtient les demandes sélectionnées
List<DemandeAide> get demandesSelectionneesEntities {
return demandes.where((demande) =>
demandesSelectionnees[demande.id] == true
).toList();
}
/// Vérifie si des données sont disponibles
bool get hasData => demandes.isNotEmpty;
/// Vérifie si des filtres sont appliqués
bool get hasFiltres => !filtres.isEmpty;
/// Obtient le texte de statut
String get statusText {
if (isRefreshing) return 'Actualisation...';
if (isLoadingMore) return 'Chargement...';
if (demandesFiltrees.isEmpty && hasData) return 'Aucun résultat pour les filtres appliqués';
if (demandesFiltrees.isEmpty) return 'Aucune demande d\'aide';
return '${demandesFiltrees.length} demande${demandesFiltrees.length > 1 ? 's' : ''}';
}
}
/// État d'erreur
class DemandesAideError extends DemandesAideState {
final String message;
final String? code;
final bool isNetworkError;
final bool canRetry;
final List<DemandeAide>? cachedData;
const DemandesAideError({
required this.message,
this.code,
this.isNetworkError = false,
this.canRetry = true,
this.cachedData,
});
@override
List<Object?> get props => [
message,
code,
isNetworkError,
canRetry,
cachedData,
];
/// Vérifie si des données en cache sont disponibles
bool get hasCachedData => cachedData != null && cachedData!.isNotEmpty;
}
/// État de succès pour une opération spécifique
class DemandesAideOperationSuccess extends DemandesAideState {
final String message;
final DemandeAide? demande;
final TypeOperationDemande operation;
const DemandesAideOperationSuccess({
required this.message,
this.demande,
required this.operation,
});
@override
List<Object?> get props => [message, demande, operation];
}
/// État de validation
class DemandesAideValidation extends DemandesAideState {
final Map<String, String> erreurs;
final bool isValid;
final DemandeAide? demande;
const DemandesAideValidation({
required this.erreurs,
required this.isValid,
this.demande,
});
@override
List<Object?> get props => [erreurs, isValid, demande];
/// Obtient la première erreur
String? get premiereErreur {
return erreurs.values.isNotEmpty ? erreurs.values.first : null;
}
/// Obtient les erreurs pour un champ spécifique
String? getErreurPourChamp(String champ) {
return erreurs[champ];
}
}
/// État d'export
class DemandesAideExporting extends DemandesAideState {
final double progress;
final String? currentStep;
const DemandesAideExporting({
required this.progress,
this.currentStep,
});
@override
List<Object?> get props => [progress, currentStep];
}
/// État d'export terminé
class DemandesAideExported extends DemandesAideState {
final String filePath;
final FormatExport format;
final int nombreDemandes;
const DemandesAideExported({
required this.filePath,
required this.format,
required this.nombreDemandes,
});
@override
List<Object> get props => [filePath, format, nombreDemandes];
}
/// Classe pour les filtres des demandes d'aide
class FiltresDemandesAide extends Equatable {
final TypeAide? typeAide;
final StatutAide? statut;
final PrioriteAide? priorite;
final bool? urgente;
final String? motCle;
final String? organisationId;
final String? demandeurId;
final DateTime? dateDebutCreation;
final DateTime? dateFinCreation;
final double? montantMin;
final double? montantMax;
const FiltresDemandesAide({
this.typeAide,
this.statut,
this.priorite,
this.urgente,
this.motCle,
this.organisationId,
this.demandeurId,
this.dateDebutCreation,
this.dateFinCreation,
this.montantMin,
this.montantMax,
});
@override
List<Object?> get props => [
typeAide,
statut,
priorite,
urgente,
motCle,
organisationId,
demandeurId,
dateDebutCreation,
dateFinCreation,
montantMin,
montantMax,
];
/// Copie les filtres avec de nouvelles valeurs
FiltresDemandesAide copyWith({
TypeAide? typeAide,
StatutAide? statut,
PrioriteAide? priorite,
bool? urgente,
String? motCle,
String? organisationId,
String? demandeurId,
DateTime? dateDebutCreation,
DateTime? dateFinCreation,
double? montantMin,
double? montantMax,
}) {
return FiltresDemandesAide(
typeAide: typeAide ?? this.typeAide,
statut: statut ?? this.statut,
priorite: priorite ?? this.priorite,
urgente: urgente ?? this.urgente,
motCle: motCle ?? this.motCle,
organisationId: organisationId ?? this.organisationId,
demandeurId: demandeurId ?? this.demandeurId,
dateDebutCreation: dateDebutCreation ?? this.dateDebutCreation,
dateFinCreation: dateFinCreation ?? this.dateFinCreation,
montantMin: montantMin ?? this.montantMin,
montantMax: montantMax ?? this.montantMax,
);
}
/// Réinitialise tous les filtres
FiltresDemandesAide clear() {
return const FiltresDemandesAide();
}
/// Vérifie si les filtres sont vides
bool get isEmpty {
return typeAide == null &&
statut == null &&
priorite == null &&
urgente == null &&
(motCle == null || motCle!.isEmpty) &&
organisationId == null &&
demandeurId == null &&
dateDebutCreation == null &&
dateFinCreation == null &&
montantMin == null &&
montantMax == null;
}
/// Obtient le nombre de filtres actifs
int get nombreFiltresActifs {
int count = 0;
if (typeAide != null) count++;
if (statut != null) count++;
if (priorite != null) count++;
if (urgente != null) count++;
if (motCle != null && motCle!.isNotEmpty) count++;
if (organisationId != null) count++;
if (demandeurId != null) count++;
if (dateDebutCreation != null) count++;
if (dateFinCreation != null) count++;
if (montantMin != null) count++;
if (montantMax != null) count++;
return count;
}
/// Obtient une description textuelle des filtres
String get description {
final parts = <String>[];
if (typeAide != null) parts.add('Type: ${typeAide!.libelle}');
if (statut != null) parts.add('Statut: ${statut!.libelle}');
if (priorite != null) parts.add('Priorité: ${priorite!.libelle}');
if (urgente == true) parts.add('Urgente uniquement');
if (motCle != null && motCle!.isNotEmpty) parts.add('Recherche: "$motCle"');
if (montantMin != null || montantMax != null) {
if (montantMin != null && montantMax != null) {
parts.add('Montant: ${montantMin!.toInt()} - ${montantMax!.toInt()} FCFA');
} else if (montantMin != null) {
parts.add('Montant min: ${montantMin!.toInt()} FCFA');
} else {
parts.add('Montant max: ${montantMax!.toInt()} FCFA');
}
}
return parts.join(', ');
}
}
/// Énumération pour les types d'opération
enum TypeOperationDemande {
creation,
modification,
soumission,
evaluation,
suppression,
export,
}
/// Extension pour obtenir le libellé des opérations
extension TypeOperationDemandeExtension on TypeOperationDemande {
String get libelle {
switch (this) {
case TypeOperationDemande.creation:
return 'Création';
case TypeOperationDemande.modification:
return 'Modification';
case TypeOperationDemande.soumission:
return 'Soumission';
case TypeOperationDemande.evaluation:
return 'Évaluation';
case TypeOperationDemande.suppression:
return 'Suppression';
case TypeOperationDemande.export:
return 'Export';
}
}
String get messageSucces {
switch (this) {
case TypeOperationDemande.creation:
return 'Demande d\'aide créée avec succès';
case TypeOperationDemande.modification:
return 'Demande d\'aide modifiée avec succès';
case TypeOperationDemande.soumission:
return 'Demande d\'aide soumise avec succès';
case TypeOperationDemande.evaluation:
return 'Demande d\'aide évaluée avec succès';
case TypeOperationDemande.suppression:
return 'Demande d\'aide supprimée avec succès';
case TypeOperationDemande.export:
return 'Export réalisé avec succès';
}
}
}

View File

@@ -0,0 +1,438 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/evaluation_aide.dart';
/// Événements pour la gestion des évaluations d'aide
///
/// Ces événements représentent toutes les actions possibles
/// que l'utilisateur peut effectuer sur les évaluations d'aide.
abstract class EvaluationsEvent extends Equatable {
const EvaluationsEvent();
@override
List<Object?> get props => [];
}
/// Événement pour charger les évaluations
class ChargerEvaluationsEvent extends EvaluationsEvent {
final String? demandeId;
final String? evaluateurId;
final TypeEvaluateur? typeEvaluateur;
final StatutAide? decision;
final bool forceRefresh;
const ChargerEvaluationsEvent({
this.demandeId,
this.evaluateurId,
this.typeEvaluateur,
this.decision,
this.forceRefresh = false,
});
@override
List<Object?> get props => [
demandeId,
evaluateurId,
typeEvaluateur,
decision,
forceRefresh,
];
}
/// Événement pour charger plus d'évaluations (pagination)
class ChargerPlusEvaluationsEvent extends EvaluationsEvent {
const ChargerPlusEvaluationsEvent();
}
/// Événement pour créer une nouvelle évaluation
class CreerEvaluationEvent extends EvaluationsEvent {
final EvaluationAide evaluation;
const CreerEvaluationEvent({required this.evaluation});
@override
List<Object> get props => [evaluation];
}
/// Événement pour mettre à jour une évaluation
class MettreAJourEvaluationEvent extends EvaluationsEvent {
final EvaluationAide evaluation;
const MettreAJourEvaluationEvent({required this.evaluation});
@override
List<Object> get props => [evaluation];
}
/// Événement pour obtenir une évaluation spécifique
class ObtenirEvaluationEvent extends EvaluationsEvent {
final String evaluationId;
const ObtenirEvaluationEvent({required this.evaluationId});
@override
List<Object> get props => [evaluationId];
}
/// Événement pour soumettre une évaluation
class SoumettreEvaluationEvent extends EvaluationsEvent {
final String evaluationId;
const SoumettreEvaluationEvent({required this.evaluationId});
@override
List<Object> get props => [evaluationId];
}
/// Événement pour approuver une évaluation
class ApprouverEvaluationEvent extends EvaluationsEvent {
final String evaluationId;
final String? commentaire;
const ApprouverEvaluationEvent({
required this.evaluationId,
this.commentaire,
});
@override
List<Object?> get props => [evaluationId, commentaire];
}
/// Événement pour rejeter une évaluation
class RejeterEvaluationEvent extends EvaluationsEvent {
final String evaluationId;
final String motifRejet;
const RejeterEvaluationEvent({
required this.evaluationId,
required this.motifRejet,
});
@override
List<Object> get props => [evaluationId, motifRejet];
}
/// Événement pour rechercher des évaluations
class RechercherEvaluationsEvent extends EvaluationsEvent {
final String? demandeId;
final String? evaluateurId;
final TypeEvaluateur? typeEvaluateur;
final StatutAide? decision;
final DateTime? dateDebut;
final DateTime? dateFin;
final double? noteMin;
final double? noteMax;
final String? motCle;
final int page;
final int taille;
const RechercherEvaluationsEvent({
this.demandeId,
this.evaluateurId,
this.typeEvaluateur,
this.decision,
this.dateDebut,
this.dateFin,
this.noteMin,
this.noteMax,
this.motCle,
this.page = 0,
this.taille = 20,
});
@override
List<Object?> get props => [
demandeId,
evaluateurId,
typeEvaluateur,
decision,
dateDebut,
dateFin,
noteMin,
noteMax,
motCle,
page,
taille,
];
}
/// Événement pour charger mes évaluations
class ChargerMesEvaluationsEvent extends EvaluationsEvent {
final String evaluateurId;
const ChargerMesEvaluationsEvent({required this.evaluateurId});
@override
List<Object> get props => [evaluateurId];
}
/// Événement pour charger les évaluations en attente
class ChargerEvaluationsEnAttenteEvent extends EvaluationsEvent {
final String? evaluateurId;
final TypeEvaluateur? typeEvaluateur;
const ChargerEvaluationsEnAttenteEvent({
this.evaluateurId,
this.typeEvaluateur,
});
@override
List<Object?> get props => [evaluateurId, typeEvaluateur];
}
/// Événement pour valider une évaluation
class ValiderEvaluationEvent extends EvaluationsEvent {
final EvaluationAide evaluation;
const ValiderEvaluationEvent({required this.evaluation});
@override
List<Object> get props => [evaluation];
}
/// Événement pour calculer la note globale
class CalculerNoteGlobaleEvent extends EvaluationsEvent {
final Map<String, double> criteres;
const CalculerNoteGlobaleEvent({required this.criteres});
@override
List<Object> get props => [criteres];
}
/// Événement pour filtrer les évaluations localement
class FiltrerEvaluationsEvent extends EvaluationsEvent {
final TypeEvaluateur? typeEvaluateur;
final StatutAide? decision;
final double? noteMin;
final double? noteMax;
final String? motCle;
final DateTime? dateDebut;
final DateTime? dateFin;
const FiltrerEvaluationsEvent({
this.typeEvaluateur,
this.decision,
this.noteMin,
this.noteMax,
this.motCle,
this.dateDebut,
this.dateFin,
});
@override
List<Object?> get props => [
typeEvaluateur,
decision,
noteMin,
noteMax,
motCle,
dateDebut,
dateFin,
];
}
/// Événement pour trier les évaluations
class TrierEvaluationsEvent extends EvaluationsEvent {
final TriEvaluations critere;
final bool croissant;
const TrierEvaluationsEvent({
required this.critere,
this.croissant = true,
});
@override
List<Object> get props => [critere, croissant];
}
/// Événement pour rafraîchir les évaluations
class RafraichirEvaluationsEvent extends EvaluationsEvent {
const RafraichirEvaluationsEvent();
}
/// Événement pour réinitialiser l'état
class ReinitialiserEvaluationsEvent extends EvaluationsEvent {
const ReinitialiserEvaluationsEvent();
}
/// Événement pour sélectionner/désélectionner une évaluation
class SelectionnerEvaluationEvent extends EvaluationsEvent {
final String evaluationId;
final bool selectionne;
const SelectionnerEvaluationEvent({
required this.evaluationId,
required this.selectionne,
});
@override
List<Object> get props => [evaluationId, selectionne];
}
/// Événement pour sélectionner/désélectionner toutes les évaluations
class SelectionnerToutesEvaluationsEvent extends EvaluationsEvent {
final bool selectionne;
const SelectionnerToutesEvaluationsEvent({required this.selectionne});
@override
List<Object> get props => [selectionne];
}
/// Événement pour supprimer des évaluations sélectionnées
class SupprimerEvaluationsSelectionnees extends EvaluationsEvent {
final List<String> evaluationIds;
const SupprimerEvaluationsSelectionnees({required this.evaluationIds});
@override
List<Object> get props => [evaluationIds];
}
/// Événement pour exporter des évaluations
class ExporterEvaluationsEvent extends EvaluationsEvent {
final List<String> evaluationIds;
final FormatExport format;
const ExporterEvaluationsEvent({
required this.evaluationIds,
required this.format,
});
@override
List<Object> get props => [evaluationIds, format];
}
/// Événement pour obtenir les statistiques d'évaluation
class ObtenirStatistiquesEvaluationEvent extends EvaluationsEvent {
final String? evaluateurId;
final DateTime? dateDebut;
final DateTime? dateFin;
const ObtenirStatistiquesEvaluationEvent({
this.evaluateurId,
this.dateDebut,
this.dateFin,
});
@override
List<Object?> get props => [evaluateurId, dateDebut, dateFin];
}
/// Événement pour signaler une évaluation
class SignalerEvaluationEvent extends EvaluationsEvent {
final String evaluationId;
final String motifSignalement;
final String? description;
const SignalerEvaluationEvent({
required this.evaluationId,
required this.motifSignalement,
this.description,
});
@override
List<Object?> get props => [evaluationId, motifSignalement, description];
}
/// Énumération pour les critères de tri
enum TriEvaluations {
dateEvaluation,
dateCreation,
noteGlobale,
decision,
evaluateur,
typeEvaluateur,
demandeId,
}
/// Énumération pour les formats d'export
enum FormatExport {
pdf,
excel,
csv,
json,
}
/// Extension pour obtenir le libellé des critères de tri
extension TriEvaluationsExtension on TriEvaluations {
String get libelle {
switch (this) {
case TriEvaluations.dateEvaluation:
return 'Date d\'évaluation';
case TriEvaluations.dateCreation:
return 'Date de création';
case TriEvaluations.noteGlobale:
return 'Note globale';
case TriEvaluations.decision:
return 'Décision';
case TriEvaluations.evaluateur:
return 'Évaluateur';
case TriEvaluations.typeEvaluateur:
return 'Type d\'évaluateur';
case TriEvaluations.demandeId:
return 'Demande';
}
}
String get icone {
switch (this) {
case TriEvaluations.dateEvaluation:
return 'calendar_today';
case TriEvaluations.dateCreation:
return 'schedule';
case TriEvaluations.noteGlobale:
return 'star';
case TriEvaluations.decision:
return 'gavel';
case TriEvaluations.evaluateur:
return 'person';
case TriEvaluations.typeEvaluateur:
return 'badge';
case TriEvaluations.demandeId:
return 'description';
}
}
}
/// Extension pour obtenir le libellé des formats d'export
extension FormatExportExtension on FormatExport {
String get libelle {
switch (this) {
case FormatExport.pdf:
return 'PDF';
case FormatExport.excel:
return 'Excel';
case FormatExport.csv:
return 'CSV';
case FormatExport.json:
return 'JSON';
}
}
String get extension {
switch (this) {
case FormatExport.pdf:
return '.pdf';
case FormatExport.excel:
return '.xlsx';
case FormatExport.csv:
return '.csv';
case FormatExport.json:
return '.json';
}
}
String get mimeType {
switch (this) {
case FormatExport.pdf:
return 'application/pdf';
case FormatExport.excel:
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case FormatExport.csv:
return 'text/csv';
case FormatExport.json:
return 'application/json';
}
}
}

View File

@@ -0,0 +1,478 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/evaluation_aide.dart';
import 'evaluations_event.dart';
/// États pour la gestion des évaluations d'aide
///
/// Ces états représentent tous les états possibles
/// de l'interface utilisateur pour les évaluations d'aide.
abstract class EvaluationsState extends Equatable {
const EvaluationsState();
@override
List<Object?> get props => [];
}
/// État initial
class EvaluationsInitial extends EvaluationsState {
const EvaluationsInitial();
}
/// État de chargement
class EvaluationsLoading extends EvaluationsState {
final bool isRefreshing;
final bool isLoadingMore;
const EvaluationsLoading({
this.isRefreshing = false,
this.isLoadingMore = false,
});
@override
List<Object> get props => [isRefreshing, isLoadingMore];
}
/// État de succès avec données chargées
class EvaluationsLoaded extends EvaluationsState {
final List<EvaluationAide> evaluations;
final List<EvaluationAide> evaluationsFiltrees;
final bool hasReachedMax;
final int currentPage;
final int totalElements;
final Map<String, bool> evaluationsSelectionnees;
final TriEvaluations? criterieTri;
final bool triCroissant;
final FiltresEvaluations filtres;
final bool isRefreshing;
final bool isLoadingMore;
final DateTime lastUpdated;
const EvaluationsLoaded({
required this.evaluations,
required this.evaluationsFiltrees,
this.hasReachedMax = false,
this.currentPage = 0,
this.totalElements = 0,
this.evaluationsSelectionnees = const {},
this.criterieTri,
this.triCroissant = true,
this.filtres = const FiltresEvaluations(),
this.isRefreshing = false,
this.isLoadingMore = false,
required this.lastUpdated,
});
@override
List<Object?> get props => [
evaluations,
evaluationsFiltrees,
hasReachedMax,
currentPage,
totalElements,
evaluationsSelectionnees,
criterieTri,
triCroissant,
filtres,
isRefreshing,
isLoadingMore,
lastUpdated,
];
/// Copie l'état avec de nouvelles valeurs
EvaluationsLoaded copyWith({
List<EvaluationAide>? evaluations,
List<EvaluationAide>? evaluationsFiltrees,
bool? hasReachedMax,
int? currentPage,
int? totalElements,
Map<String, bool>? evaluationsSelectionnees,
TriEvaluations? criterieTri,
bool? triCroissant,
FiltresEvaluations? filtres,
bool? isRefreshing,
bool? isLoadingMore,
DateTime? lastUpdated,
}) {
return EvaluationsLoaded(
evaluations: evaluations ?? this.evaluations,
evaluationsFiltrees: evaluationsFiltrees ?? this.evaluationsFiltrees,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
currentPage: currentPage ?? this.currentPage,
totalElements: totalElements ?? this.totalElements,
evaluationsSelectionnees: evaluationsSelectionnees ?? this.evaluationsSelectionnees,
criterieTri: criterieTri ?? this.criterieTri,
triCroissant: triCroissant ?? this.triCroissant,
filtres: filtres ?? this.filtres,
isRefreshing: isRefreshing ?? this.isRefreshing,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
lastUpdated: lastUpdated ?? this.lastUpdated,
);
}
/// Obtient le nombre d'évaluations sélectionnées
int get nombreEvaluationsSelectionnees {
return evaluationsSelectionnees.values.where((selected) => selected).length;
}
/// Vérifie si toutes les évaluations sont sélectionnées
bool get toutesEvaluationsSelectionnees {
if (evaluationsFiltrees.isEmpty) return false;
return evaluationsFiltrees.every((evaluation) =>
evaluationsSelectionnees[evaluation.id] == true
);
}
/// Obtient les IDs des évaluations sélectionnées
List<String> get evaluationsSelectionneesIds {
return evaluationsSelectionnees.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toList();
}
/// Obtient les évaluations sélectionnées
List<EvaluationAide> get evaluationsSelectionneesEntities {
return evaluations.where((evaluation) =>
evaluationsSelectionnees[evaluation.id] == true
).toList();
}
/// Vérifie si des données sont disponibles
bool get hasData => evaluations.isNotEmpty;
/// Vérifie si des filtres sont appliqués
bool get hasFiltres => !filtres.isEmpty;
/// Obtient le texte de statut
String get statusText {
if (isRefreshing) return 'Actualisation...';
if (isLoadingMore) return 'Chargement...';
if (evaluationsFiltrees.isEmpty && hasData) return 'Aucun résultat pour les filtres appliqués';
if (evaluationsFiltrees.isEmpty) return 'Aucune évaluation';
return '${evaluationsFiltrees.length} évaluation${evaluationsFiltrees.length > 1 ? 's' : ''}';
}
/// Obtient la note moyenne
double get noteMoyenne {
if (evaluationsFiltrees.isEmpty) return 0.0;
final notesValides = evaluationsFiltrees
.where((e) => e.noteGlobale != null)
.map((e) => e.noteGlobale!)
.toList();
if (notesValides.isEmpty) return 0.0;
return notesValides.reduce((a, b) => a + b) / notesValides.length;
}
/// Obtient le nombre d'évaluations par décision
Map<StatutAide, int> get repartitionDecisions {
final repartition = <StatutAide, int>{};
for (final evaluation in evaluationsFiltrees) {
repartition[evaluation.decision] = (repartition[evaluation.decision] ?? 0) + 1;
}
return repartition;
}
}
/// État d'erreur
class EvaluationsError extends EvaluationsState {
final String message;
final String? code;
final bool isNetworkError;
final bool canRetry;
final List<EvaluationAide>? cachedData;
const EvaluationsError({
required this.message,
this.code,
this.isNetworkError = false,
this.canRetry = true,
this.cachedData,
});
@override
List<Object?> get props => [
message,
code,
isNetworkError,
canRetry,
cachedData,
];
/// Vérifie si des données en cache sont disponibles
bool get hasCachedData => cachedData != null && cachedData!.isNotEmpty;
}
/// État de succès pour une opération spécifique
class EvaluationsOperationSuccess extends EvaluationsState {
final String message;
final EvaluationAide? evaluation;
final TypeOperationEvaluation operation;
const EvaluationsOperationSuccess({
required this.message,
this.evaluation,
required this.operation,
});
@override
List<Object?> get props => [message, evaluation, operation];
}
/// État de validation
class EvaluationsValidation extends EvaluationsState {
final Map<String, String> erreurs;
final bool isValid;
final EvaluationAide? evaluation;
const EvaluationsValidation({
required this.erreurs,
required this.isValid,
this.evaluation,
});
@override
List<Object?> get props => [erreurs, isValid, evaluation];
/// Obtient la première erreur
String? get premiereErreur {
return erreurs.values.isNotEmpty ? erreurs.values.first : null;
}
/// Obtient les erreurs pour un champ spécifique
String? getErreurPourChamp(String champ) {
return erreurs[champ];
}
}
/// État de calcul de note globale
class EvaluationsNoteCalculee extends EvaluationsState {
final double noteGlobale;
final Map<String, double> criteres;
const EvaluationsNoteCalculee({
required this.noteGlobale,
required this.criteres,
});
@override
List<Object> get props => [noteGlobale, criteres];
}
/// État des statistiques d'évaluation
class EvaluationsStatistiques extends EvaluationsState {
final Map<String, dynamic> statistiques;
final DateTime? dateDebut;
final DateTime? dateFin;
const EvaluationsStatistiques({
required this.statistiques,
this.dateDebut,
this.dateFin,
});
@override
List<Object?> get props => [statistiques, dateDebut, dateFin];
}
/// État d'export
class EvaluationsExporting extends EvaluationsState {
final double progress;
final String? currentStep;
const EvaluationsExporting({
required this.progress,
this.currentStep,
});
@override
List<Object?> get props => [progress, currentStep];
}
/// État d'export terminé
class EvaluationsExported extends EvaluationsState {
final String filePath;
final FormatExport format;
final int nombreEvaluations;
const EvaluationsExported({
required this.filePath,
required this.format,
required this.nombreEvaluations,
});
@override
List<Object> get props => [filePath, format, nombreEvaluations];
}
/// Classe pour les filtres des évaluations
class FiltresEvaluations extends Equatable {
final TypeEvaluateur? typeEvaluateur;
final StatutAide? decision;
final double? noteMin;
final double? noteMax;
final String? motCle;
final String? evaluateurId;
final String? demandeId;
final DateTime? dateDebutEvaluation;
final DateTime? dateFinEvaluation;
const FiltresEvaluations({
this.typeEvaluateur,
this.decision,
this.noteMin,
this.noteMax,
this.motCle,
this.evaluateurId,
this.demandeId,
this.dateDebutEvaluation,
this.dateFinEvaluation,
});
@override
List<Object?> get props => [
typeEvaluateur,
decision,
noteMin,
noteMax,
motCle,
evaluateurId,
demandeId,
dateDebutEvaluation,
dateFinEvaluation,
];
/// Copie les filtres avec de nouvelles valeurs
FiltresEvaluations copyWith({
TypeEvaluateur? typeEvaluateur,
StatutAide? decision,
double? noteMin,
double? noteMax,
String? motCle,
String? evaluateurId,
String? demandeId,
DateTime? dateDebutEvaluation,
DateTime? dateFinEvaluation,
}) {
return FiltresEvaluations(
typeEvaluateur: typeEvaluateur ?? this.typeEvaluateur,
decision: decision ?? this.decision,
noteMin: noteMin ?? this.noteMin,
noteMax: noteMax ?? this.noteMax,
motCle: motCle ?? this.motCle,
evaluateurId: evaluateurId ?? this.evaluateurId,
demandeId: demandeId ?? this.demandeId,
dateDebutEvaluation: dateDebutEvaluation ?? this.dateDebutEvaluation,
dateFinEvaluation: dateFinEvaluation ?? this.dateFinEvaluation,
);
}
/// Réinitialise tous les filtres
FiltresEvaluations clear() {
return const FiltresEvaluations();
}
/// Vérifie si les filtres sont vides
bool get isEmpty {
return typeEvaluateur == null &&
decision == null &&
noteMin == null &&
noteMax == null &&
(motCle == null || motCle!.isEmpty) &&
evaluateurId == null &&
demandeId == null &&
dateDebutEvaluation == null &&
dateFinEvaluation == null;
}
/// Obtient le nombre de filtres actifs
int get nombreFiltresActifs {
int count = 0;
if (typeEvaluateur != null) count++;
if (decision != null) count++;
if (noteMin != null) count++;
if (noteMax != null) count++;
if (motCle != null && motCle!.isNotEmpty) count++;
if (evaluateurId != null) count++;
if (demandeId != null) count++;
if (dateDebutEvaluation != null) count++;
if (dateFinEvaluation != null) count++;
return count;
}
/// Obtient une description textuelle des filtres
String get description {
final parts = <String>[];
if (typeEvaluateur != null) parts.add('Type: ${typeEvaluateur!.libelle}');
if (decision != null) parts.add('Décision: ${decision!.libelle}');
if (motCle != null && motCle!.isNotEmpty) parts.add('Recherche: "$motCle"');
if (noteMin != null || noteMax != null) {
if (noteMin != null && noteMax != null) {
parts.add('Note: ${noteMin!.toStringAsFixed(1)} - ${noteMax!.toStringAsFixed(1)}');
} else if (noteMin != null) {
parts.add('Note min: ${noteMin!.toStringAsFixed(1)}');
} else {
parts.add('Note max: ${noteMax!.toStringAsFixed(1)}');
}
}
return parts.join(', ');
}
}
/// Énumération pour les types d'opération
enum TypeOperationEvaluation {
creation,
modification,
soumission,
approbation,
rejet,
suppression,
export,
signalement,
}
/// Extension pour obtenir le libellé des opérations
extension TypeOperationEvaluationExtension on TypeOperationEvaluation {
String get libelle {
switch (this) {
case TypeOperationEvaluation.creation:
return 'Création';
case TypeOperationEvaluation.modification:
return 'Modification';
case TypeOperationEvaluation.soumission:
return 'Soumission';
case TypeOperationEvaluation.approbation:
return 'Approbation';
case TypeOperationEvaluation.rejet:
return 'Rejet';
case TypeOperationEvaluation.suppression:
return 'Suppression';
case TypeOperationEvaluation.export:
return 'Export';
case TypeOperationEvaluation.signalement:
return 'Signalement';
}
}
String get messageSucces {
switch (this) {
case TypeOperationEvaluation.creation:
return 'Évaluation créée avec succès';
case TypeOperationEvaluation.modification:
return 'Évaluation modifiée avec succès';
case TypeOperationEvaluation.soumission:
return 'Évaluation soumise avec succès';
case TypeOperationEvaluation.approbation:
return 'Évaluation approuvée avec succès';
case TypeOperationEvaluation.rejet:
return 'Évaluation rejetée avec succès';
case TypeOperationEvaluation.suppression:
return 'Évaluation supprimée avec succès';
case TypeOperationEvaluation.export:
return 'Export réalisé avec succès';
case TypeOperationEvaluation.signalement:
return 'Évaluation signalée avec succès';
}
}
}

View File

@@ -0,0 +1,382 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/proposition_aide.dart';
/// Événements pour la gestion des propositions d'aide
///
/// Ces événements représentent toutes les actions possibles
/// que l'utilisateur peut effectuer sur les propositions d'aide.
abstract class PropositionsAideEvent extends Equatable {
const PropositionsAideEvent();
@override
List<Object?> get props => [];
}
/// Événement pour charger les propositions d'aide
class ChargerPropositionsAideEvent extends PropositionsAideEvent {
final String? organisationId;
final TypeAide? typeAide;
final StatutProposition? statut;
final String? proposantId;
final bool? disponible;
final bool forceRefresh;
const ChargerPropositionsAideEvent({
this.organisationId,
this.typeAide,
this.statut,
this.proposantId,
this.disponible,
this.forceRefresh = false,
});
@override
List<Object?> get props => [
organisationId,
typeAide,
statut,
proposantId,
disponible,
forceRefresh,
];
}
/// Événement pour charger plus de propositions (pagination)
class ChargerPlusPropositionsAideEvent extends PropositionsAideEvent {
const ChargerPlusPropositionsAideEvent();
}
/// Événement pour créer une nouvelle proposition d'aide
class CreerPropositionAideEvent extends PropositionsAideEvent {
final PropositionAide proposition;
const CreerPropositionAideEvent({required this.proposition});
@override
List<Object> get props => [proposition];
}
/// Événement pour mettre à jour une proposition d'aide
class MettreAJourPropositionAideEvent extends PropositionsAideEvent {
final PropositionAide proposition;
const MettreAJourPropositionAideEvent({required this.proposition});
@override
List<Object> get props => [proposition];
}
/// Événement pour obtenir une proposition d'aide spécifique
class ObtenirPropositionAideEvent extends PropositionsAideEvent {
final String propositionId;
const ObtenirPropositionAideEvent({required this.propositionId});
@override
List<Object> get props => [propositionId];
}
/// Événement pour activer/désactiver une proposition
class ToggleDisponibilitePropositionEvent extends PropositionsAideEvent {
final String propositionId;
final bool disponible;
const ToggleDisponibilitePropositionEvent({
required this.propositionId,
required this.disponible,
});
@override
List<Object> get props => [propositionId, disponible];
}
/// Événement pour rechercher des propositions d'aide
class RechercherPropositionsAideEvent extends PropositionsAideEvent {
final String? organisationId;
final TypeAide? typeAide;
final StatutProposition? statut;
final String? proposantId;
final bool? disponible;
final String? motCle;
final int page;
final int taille;
const RechercherPropositionsAideEvent({
this.organisationId,
this.typeAide,
this.statut,
this.proposantId,
this.disponible,
this.motCle,
this.page = 0,
this.taille = 20,
});
@override
List<Object?> get props => [
organisationId,
typeAide,
statut,
proposantId,
disponible,
motCle,
page,
taille,
];
}
/// Événement pour charger mes propositions
class ChargerMesPropositionsEvent extends PropositionsAideEvent {
final String utilisateurId;
const ChargerMesPropositionsEvent({required this.utilisateurId});
@override
List<Object> get props => [utilisateurId];
}
/// Événement pour charger les propositions disponibles
class ChargerPropositionsDisponiblesEvent extends PropositionsAideEvent {
final String organisationId;
final TypeAide? typeAide;
const ChargerPropositionsDisponiblesEvent({
required this.organisationId,
this.typeAide,
});
@override
List<Object?> get props => [organisationId, typeAide];
}
/// Événement pour filtrer les propositions localement
class FiltrerPropositionsAideEvent extends PropositionsAideEvent {
final TypeAide? typeAide;
final StatutProposition? statut;
final bool? disponible;
final String? motCle;
final double? capaciteMin;
final double? capaciteMax;
const FiltrerPropositionsAideEvent({
this.typeAide,
this.statut,
this.disponible,
this.motCle,
this.capaciteMin,
this.capaciteMax,
});
@override
List<Object?> get props => [
typeAide,
statut,
disponible,
motCle,
capaciteMin,
capaciteMax,
];
}
/// Événement pour trier les propositions
class TrierPropositionsAideEvent extends PropositionsAideEvent {
final TriPropositions critere;
final bool croissant;
const TrierPropositionsAideEvent({
required this.critere,
this.croissant = true,
});
@override
List<Object> get props => [critere, croissant];
}
/// Événement pour rafraîchir les propositions
class RafraichirPropositionsAideEvent extends PropositionsAideEvent {
const RafraichirPropositionsAideEvent();
}
/// Événement pour réinitialiser l'état
class ReinitialiserPropositionsAideEvent extends PropositionsAideEvent {
const ReinitialiserPropositionsAideEvent();
}
/// Événement pour sélectionner/désélectionner une proposition
class SelectionnerPropositionAideEvent extends PropositionsAideEvent {
final String propositionId;
final bool selectionne;
const SelectionnerPropositionAideEvent({
required this.propositionId,
required this.selectionne,
});
@override
List<Object> get props => [propositionId, selectionne];
}
/// Événement pour sélectionner/désélectionner toutes les propositions
class SelectionnerToutesPropositionsAideEvent extends PropositionsAideEvent {
final bool selectionne;
const SelectionnerToutesPropositionsAideEvent({required this.selectionne});
@override
List<Object> get props => [selectionne];
}
/// Événement pour supprimer des propositions sélectionnées
class SupprimerPropositionsSelectionnees extends PropositionsAideEvent {
final List<String> propositionIds;
const SupprimerPropositionsSelectionnees({required this.propositionIds});
@override
List<Object> get props => [propositionIds];
}
/// Événement pour exporter des propositions
class ExporterPropositionsAideEvent extends PropositionsAideEvent {
final List<String> propositionIds;
final FormatExport format;
const ExporterPropositionsAideEvent({
required this.propositionIds,
required this.format,
});
@override
List<Object> get props => [propositionIds, format];
}
/// Événement pour calculer la compatibilité avec une demande
class CalculerCompatibiliteEvent extends PropositionsAideEvent {
final String propositionId;
final String demandeId;
const CalculerCompatibiliteEvent({
required this.propositionId,
required this.demandeId,
});
@override
List<Object> get props => [propositionId, demandeId];
}
/// Événement pour obtenir les statistiques d'une proposition
class ObtenirStatistiquesPropositionEvent extends PropositionsAideEvent {
final String propositionId;
const ObtenirStatistiquesPropositionEvent({required this.propositionId});
@override
List<Object> get props => [propositionId];
}
/// Énumération pour les critères de tri
enum TriPropositions {
dateCreation,
dateModification,
titre,
statut,
capacite,
proposant,
scoreCompatibilite,
nombreMatches,
}
/// Énumération pour les formats d'export
enum FormatExport {
pdf,
excel,
csv,
json,
}
/// Extension pour obtenir le libellé des critères de tri
extension TriPropositionsExtension on TriPropositions {
String get libelle {
switch (this) {
case TriPropositions.dateCreation:
return 'Date de création';
case TriPropositions.dateModification:
return 'Date de modification';
case TriPropositions.titre:
return 'Titre';
case TriPropositions.statut:
return 'Statut';
case TriPropositions.capacite:
return 'Capacité';
case TriPropositions.proposant:
return 'Proposant';
case TriPropositions.scoreCompatibilite:
return 'Score de compatibilité';
case TriPropositions.nombreMatches:
return 'Nombre de matches';
}
}
String get icone {
switch (this) {
case TriPropositions.dateCreation:
return 'calendar_today';
case TriPropositions.dateModification:
return 'update';
case TriPropositions.titre:
return 'title';
case TriPropositions.statut:
return 'flag';
case TriPropositions.capacite:
return 'trending_up';
case TriPropositions.proposant:
return 'person';
case TriPropositions.scoreCompatibilite:
return 'star';
case TriPropositions.nombreMatches:
return 'link';
}
}
}
/// Extension pour obtenir le libellé des formats d'export
extension FormatExportExtension on FormatExport {
String get libelle {
switch (this) {
case FormatExport.pdf:
return 'PDF';
case FormatExport.excel:
return 'Excel';
case FormatExport.csv:
return 'CSV';
case FormatExport.json:
return 'JSON';
}
}
String get extension {
switch (this) {
case FormatExport.pdf:
return '.pdf';
case FormatExport.excel:
return '.xlsx';
case FormatExport.csv:
return '.csv';
case FormatExport.json:
return '.json';
}
}
String get mimeType {
switch (this) {
case FormatExport.pdf:
return 'application/pdf';
case FormatExport.excel:
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case FormatExport.csv:
return 'text/csv';
case FormatExport.json:
return 'application/json';
}
}
}

View File

@@ -0,0 +1,445 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/proposition_aide.dart';
import 'propositions_aide_event.dart';
/// États pour la gestion des propositions d'aide
///
/// Ces états représentent tous les états possibles
/// de l'interface utilisateur pour les propositions d'aide.
abstract class PropositionsAideState extends Equatable {
const PropositionsAideState();
@override
List<Object?> get props => [];
}
/// État initial
class PropositionsAideInitial extends PropositionsAideState {
const PropositionsAideInitial();
}
/// État de chargement
class PropositionsAideLoading extends PropositionsAideState {
final bool isRefreshing;
final bool isLoadingMore;
const PropositionsAideLoading({
this.isRefreshing = false,
this.isLoadingMore = false,
});
@override
List<Object> get props => [isRefreshing, isLoadingMore];
}
/// État de succès avec données chargées
class PropositionsAideLoaded extends PropositionsAideState {
final List<PropositionAide> propositions;
final List<PropositionAide> propositionsFiltrees;
final bool hasReachedMax;
final int currentPage;
final int totalElements;
final Map<String, bool> propositionsSelectionnees;
final TriPropositions? criterieTri;
final bool triCroissant;
final FiltresPropositionsAide filtres;
final bool isRefreshing;
final bool isLoadingMore;
final DateTime lastUpdated;
const PropositionsAideLoaded({
required this.propositions,
required this.propositionsFiltrees,
this.hasReachedMax = false,
this.currentPage = 0,
this.totalElements = 0,
this.propositionsSelectionnees = const {},
this.criterieTri,
this.triCroissant = true,
this.filtres = const FiltresPropositionsAide(),
this.isRefreshing = false,
this.isLoadingMore = false,
required this.lastUpdated,
});
@override
List<Object?> get props => [
propositions,
propositionsFiltrees,
hasReachedMax,
currentPage,
totalElements,
propositionsSelectionnees,
criterieTri,
triCroissant,
filtres,
isRefreshing,
isLoadingMore,
lastUpdated,
];
/// Copie l'état avec de nouvelles valeurs
PropositionsAideLoaded copyWith({
List<PropositionAide>? propositions,
List<PropositionAide>? propositionsFiltrees,
bool? hasReachedMax,
int? currentPage,
int? totalElements,
Map<String, bool>? propositionsSelectionnees,
TriPropositions? criterieTri,
bool? triCroissant,
FiltresPropositionsAide? filtres,
bool? isRefreshing,
bool? isLoadingMore,
DateTime? lastUpdated,
}) {
return PropositionsAideLoaded(
propositions: propositions ?? this.propositions,
propositionsFiltrees: propositionsFiltrees ?? this.propositionsFiltrees,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
currentPage: currentPage ?? this.currentPage,
totalElements: totalElements ?? this.totalElements,
propositionsSelectionnees: propositionsSelectionnees ?? this.propositionsSelectionnees,
criterieTri: criterieTri ?? this.criterieTri,
triCroissant: triCroissant ?? this.triCroissant,
filtres: filtres ?? this.filtres,
isRefreshing: isRefreshing ?? this.isRefreshing,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
lastUpdated: lastUpdated ?? this.lastUpdated,
);
}
/// Obtient le nombre de propositions sélectionnées
int get nombrePropositionsSelectionnees {
return propositionsSelectionnees.values.where((selected) => selected).length;
}
/// Vérifie si toutes les propositions sont sélectionnées
bool get toutesPropositionsSelectionnees {
if (propositionsFiltrees.isEmpty) return false;
return propositionsFiltrees.every((proposition) =>
propositionsSelectionnees[proposition.id] == true
);
}
/// Obtient les IDs des propositions sélectionnées
List<String> get propositionsSelectionneesIds {
return propositionsSelectionnees.entries
.where((entry) => entry.value)
.map((entry) => entry.key)
.toList();
}
/// Obtient les propositions sélectionnées
List<PropositionAide> get propositionsSelectionneesEntities {
return propositions.where((proposition) =>
propositionsSelectionnees[proposition.id] == true
).toList();
}
/// Vérifie si des données sont disponibles
bool get hasData => propositions.isNotEmpty;
/// Vérifie si des filtres sont appliqués
bool get hasFiltres => !filtres.isEmpty;
/// Obtient le texte de statut
String get statusText {
if (isRefreshing) return 'Actualisation...';
if (isLoadingMore) return 'Chargement...';
if (propositionsFiltrees.isEmpty && hasData) return 'Aucun résultat pour les filtres appliqués';
if (propositionsFiltrees.isEmpty) return 'Aucune proposition d\'aide';
return '${propositionsFiltrees.length} proposition${propositionsFiltrees.length > 1 ? 's' : ''}';
}
/// Obtient le nombre de propositions disponibles
int get nombrePropositionsDisponibles {
return propositionsFiltrees.where((p) => p.estDisponible).length;
}
/// Obtient la capacité totale disponible
double get capaciteTotaleDisponible {
return propositionsFiltrees
.where((p) => p.estDisponible)
.fold(0.0, (sum, p) => sum + (p.capaciteMaximale ?? 0.0));
}
}
/// État d'erreur
class PropositionsAideError extends PropositionsAideState {
final String message;
final String? code;
final bool isNetworkError;
final bool canRetry;
final List<PropositionAide>? cachedData;
const PropositionsAideError({
required this.message,
this.code,
this.isNetworkError = false,
this.canRetry = true,
this.cachedData,
});
@override
List<Object?> get props => [
message,
code,
isNetworkError,
canRetry,
cachedData,
];
/// Vérifie si des données en cache sont disponibles
bool get hasCachedData => cachedData != null && cachedData!.isNotEmpty;
}
/// État de succès pour une opération spécifique
class PropositionsAideOperationSuccess extends PropositionsAideState {
final String message;
final PropositionAide? proposition;
final TypeOperationProposition operation;
const PropositionsAideOperationSuccess({
required this.message,
this.proposition,
required this.operation,
});
@override
List<Object?> get props => [message, proposition, operation];
}
/// État de compatibilité calculée
class PropositionsAideCompatibilite extends PropositionsAideState {
final String propositionId;
final String demandeId;
final double scoreCompatibilite;
final Map<String, dynamic> detailsCompatibilite;
const PropositionsAideCompatibilite({
required this.propositionId,
required this.demandeId,
required this.scoreCompatibilite,
required this.detailsCompatibilite,
});
@override
List<Object> get props => [propositionId, demandeId, scoreCompatibilite, detailsCompatibilite];
}
/// État des statistiques d'une proposition
class PropositionsAideStatistiques extends PropositionsAideState {
final String propositionId;
final Map<String, dynamic> statistiques;
const PropositionsAideStatistiques({
required this.propositionId,
required this.statistiques,
});
@override
List<Object> get props => [propositionId, statistiques];
}
/// État d'export
class PropositionsAideExporting extends PropositionsAideState {
final double progress;
final String? currentStep;
const PropositionsAideExporting({
required this.progress,
this.currentStep,
});
@override
List<Object?> get props => [progress, currentStep];
}
/// État d'export terminé
class PropositionsAideExported extends PropositionsAideState {
final String filePath;
final FormatExport format;
final int nombrePropositions;
const PropositionsAideExported({
required this.filePath,
required this.format,
required this.nombrePropositions,
});
@override
List<Object> get props => [filePath, format, nombrePropositions];
}
/// Classe pour les filtres des propositions d'aide
class FiltresPropositionsAide extends Equatable {
final TypeAide? typeAide;
final StatutProposition? statut;
final bool? disponible;
final String? motCle;
final String? organisationId;
final String? proposantId;
final DateTime? dateDebutCreation;
final DateTime? dateFinCreation;
final double? capaciteMin;
final double? capaciteMax;
const FiltresPropositionsAide({
this.typeAide,
this.statut,
this.disponible,
this.motCle,
this.organisationId,
this.proposantId,
this.dateDebutCreation,
this.dateFinCreation,
this.capaciteMin,
this.capaciteMax,
});
@override
List<Object?> get props => [
typeAide,
statut,
disponible,
motCle,
organisationId,
proposantId,
dateDebutCreation,
dateFinCreation,
capaciteMin,
capaciteMax,
];
/// Copie les filtres avec de nouvelles valeurs
FiltresPropositionsAide copyWith({
TypeAide? typeAide,
StatutProposition? statut,
bool? disponible,
String? motCle,
String? organisationId,
String? proposantId,
DateTime? dateDebutCreation,
DateTime? dateFinCreation,
double? capaciteMin,
double? capaciteMax,
}) {
return FiltresPropositionsAide(
typeAide: typeAide ?? this.typeAide,
statut: statut ?? this.statut,
disponible: disponible ?? this.disponible,
motCle: motCle ?? this.motCle,
organisationId: organisationId ?? this.organisationId,
proposantId: proposantId ?? this.proposantId,
dateDebutCreation: dateDebutCreation ?? this.dateDebutCreation,
dateFinCreation: dateFinCreation ?? this.dateFinCreation,
capaciteMin: capaciteMin ?? this.capaciteMin,
capaciteMax: capaciteMax ?? this.capaciteMax,
);
}
/// Réinitialise tous les filtres
FiltresPropositionsAide clear() {
return const FiltresPropositionsAide();
}
/// Vérifie si les filtres sont vides
bool get isEmpty {
return typeAide == null &&
statut == null &&
disponible == null &&
(motCle == null || motCle!.isEmpty) &&
organisationId == null &&
proposantId == null &&
dateDebutCreation == null &&
dateFinCreation == null &&
capaciteMin == null &&
capaciteMax == null;
}
/// Obtient le nombre de filtres actifs
int get nombreFiltresActifs {
int count = 0;
if (typeAide != null) count++;
if (statut != null) count++;
if (disponible != null) count++;
if (motCle != null && motCle!.isNotEmpty) count++;
if (organisationId != null) count++;
if (proposantId != null) count++;
if (dateDebutCreation != null) count++;
if (dateFinCreation != null) count++;
if (capaciteMin != null) count++;
if (capaciteMax != null) count++;
return count;
}
/// Obtient une description textuelle des filtres
String get description {
final parts = <String>[];
if (typeAide != null) parts.add('Type: ${typeAide!.libelle}');
if (statut != null) parts.add('Statut: ${statut!.libelle}');
if (disponible == true) parts.add('Disponible uniquement');
if (disponible == false) parts.add('Non disponible uniquement');
if (motCle != null && motCle!.isNotEmpty) parts.add('Recherche: "$motCle"');
if (capaciteMin != null || capaciteMax != null) {
if (capaciteMin != null && capaciteMax != null) {
parts.add('Capacité: ${capaciteMin!.toInt()} - ${capaciteMax!.toInt()}');
} else if (capaciteMin != null) {
parts.add('Capacité min: ${capaciteMin!.toInt()}');
} else {
parts.add('Capacité max: ${capaciteMax!.toInt()}');
}
}
return parts.join(', ');
}
}
/// Énumération pour les types d'opération
enum TypeOperationProposition {
creation,
modification,
activation,
desactivation,
suppression,
export,
}
/// Extension pour obtenir le libellé des opérations
extension TypeOperationPropositionExtension on TypeOperationProposition {
String get libelle {
switch (this) {
case TypeOperationProposition.creation:
return 'Création';
case TypeOperationProposition.modification:
return 'Modification';
case TypeOperationProposition.activation:
return 'Activation';
case TypeOperationProposition.desactivation:
return 'Désactivation';
case TypeOperationProposition.suppression:
return 'Suppression';
case TypeOperationProposition.export:
return 'Export';
}
}
String get messageSucces {
switch (this) {
case TypeOperationProposition.creation:
return 'Proposition d\'aide créée avec succès';
case TypeOperationProposition.modification:
return 'Proposition d\'aide modifiée avec succès';
case TypeOperationProposition.activation:
return 'Proposition d\'aide activée avec succès';
case TypeOperationProposition.desactivation:
return 'Proposition d\'aide désactivée avec succès';
case TypeOperationProposition.suppression:
return 'Proposition d\'aide supprimée avec succès';
case TypeOperationProposition.export:
return 'Export réalisé avec succès';
}
}
}

View File

@@ -0,0 +1,770 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/widgets/unified_page_layout.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/utils/date_formatter.dart';
import '../../../../core/utils/currency_formatter.dart';
import '../../domain/entities/demande_aide.dart';
import '../bloc/demandes_aide/demandes_aide_bloc.dart';
import '../bloc/demandes_aide/demandes_aide_event.dart';
import '../bloc/demandes_aide/demandes_aide_state.dart';
import '../widgets/demande_aide_status_timeline.dart';
import '../widgets/demande_aide_evaluation_section.dart';
import '../widgets/demande_aide_documents_section.dart';
/// Page de détails d'une demande d'aide
///
/// Cette page affiche toutes les informations détaillées d'une demande d'aide
/// avec des sections organisées et des actions contextuelles.
class DemandeAideDetailsPage extends StatefulWidget {
final String demandeId;
const DemandeAideDetailsPage({
super.key,
required this.demandeId,
});
@override
State<DemandeAideDetailsPage> createState() => _DemandeAideDetailsPageState();
}
class _DemandeAideDetailsPageState extends State<DemandeAideDetailsPage> {
@override
void initState() {
super.initState();
// Charger les détails de la demande
context.read<DemandesAideBloc>().add(
ObtenirDemandeAideEvent(demandeId: widget.demandeId),
);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<DemandesAideBloc, DemandesAideState>(
listener: (context, state) {
if (state is DemandesAideError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.error,
),
);
} else if (state is DemandesAideOperationSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.success,
),
);
}
},
builder: (context, state) {
if (state is DemandesAideLoading) {
return const UnifiedPageLayout(
title: 'Détails de la demande',
body: Center(child: CircularProgressIndicator()),
);
}
if (state is DemandesAideError && !state.hasCachedData) {
return UnifiedPageLayout(
title: 'Détails de la demande',
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error,
size: 64,
color: AppColors.error,
),
const SizedBox(height: 16),
Text(
state.message,
style: AppTextStyles.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (state.canRetry)
ElevatedButton(
onPressed: () => _rechargerDemande(),
child: const Text('Réessayer'),
),
],
),
),
);
}
// Trouver la demande dans l'état
DemandeAide? demande;
if (state is DemandesAideLoaded) {
demande = state.demandes.firstWhere(
(d) => d.id == widget.demandeId,
orElse: () => throw StateError('Demande non trouvée'),
);
}
if (demande == null) {
return const UnifiedPageLayout(
title: 'Détails de la demande',
body: Center(
child: Text('Demande d\'aide non trouvée'),
),
);
}
return UnifiedPageLayout(
title: 'Détails de la demande',
actions: _buildActions(demande),
body: RefreshIndicator(
onRefresh: () async => _rechargerDemande(),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeaderSection(demande),
const SizedBox(height: 16),
_buildInfoGeneralesSection(demande),
const SizedBox(height: 16),
_buildDescriptionSection(demande),
const SizedBox(height: 16),
_buildBeneficiaireSection(demande),
const SizedBox(height: 16),
_buildContactUrgenceSection(demande),
const SizedBox(height: 16),
_buildLocalisationSection(demande),
const SizedBox(height: 16),
DemandeAideDocumentsSection(demande: demande),
const SizedBox(height: 16),
DemandeAideStatusTimeline(demande: demande),
const SizedBox(height: 16),
if (demande.evaluations.isNotEmpty)
DemandeAideEvaluationSection(demande: demande),
const SizedBox(height: 80), // Espace pour le FAB
],
),
),
),
floatingActionButton: _buildFloatingActionButton(demande),
);
},
);
}
List<Widget> _buildActions(DemandeAide demande) {
return [
PopupMenuButton<String>(
onSelected: (value) => _onMenuSelected(value, demande),
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: ListTile(
leading: Icon(Icons.edit),
title: Text('Modifier'),
dense: true,
),
),
if (demande.statut == StatutAide.brouillon)
const PopupMenuItem(
value: 'submit',
child: ListTile(
leading: Icon(Icons.send),
title: Text('Soumettre'),
dense: true,
),
),
if (demande.statut == StatutAide.soumise)
const PopupMenuItem(
value: 'evaluate',
child: ListTile(
leading: Icon(Icons.rate_review),
title: Text('Évaluer'),
dense: true,
),
),
const PopupMenuItem(
value: 'share',
child: ListTile(
leading: Icon(Icons.share),
title: Text('Partager'),
dense: true,
),
),
const PopupMenuItem(
value: 'export',
child: ListTile(
leading: Icon(Icons.file_download),
title: Text('Exporter'),
dense: true,
),
),
if (demande.statut == StatutAide.brouillon)
const PopupMenuItem(
value: 'delete',
child: ListTile(
leading: Icon(Icons.delete, color: AppColors.error),
title: Text('Supprimer', style: TextStyle(color: AppColors.error)),
dense: true,
),
),
],
),
];
}
Widget _buildFloatingActionButton(DemandeAide demande) {
if (demande.statut == StatutAide.brouillon) {
return FloatingActionButton.extended(
onPressed: () => _soumettredemande(demande),
icon: const Icon(Icons.send),
label: const Text('Soumettre'),
backgroundColor: AppColors.primary,
);
}
if (demande.statut == StatutAide.soumise) {
return FloatingActionButton.extended(
onPressed: () => _evaluerDemande(demande),
icon: const Icon(Icons.rate_review),
label: const Text('Évaluer'),
backgroundColor: AppColors.warning,
);
}
return FloatingActionButton(
onPressed: () => _modifierDemande(demande),
child: const Icon(Icons.edit),
backgroundColor: AppColors.primary,
);
}
Widget _buildHeaderSection(DemandeAide demande) {
return UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
demande.titre,
style: AppTextStyles.titleLarge.copyWith(
fontWeight: FontWeight.bold,
),
),
),
_buildStatutChip(demande.statut),
],
),
const SizedBox(height: 8),
Row(
children: [
Text(
demande.numeroReference,
style: AppTextStyles.bodyMedium.copyWith(
fontFamily: 'monospace',
color: AppColors.textSecondary,
),
),
const Spacer(),
if (demande.estUrgente)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.priority_high,
size: 16,
color: AppColors.error,
),
const SizedBox(width: 4),
Text(
'URGENT',
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.error,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 12),
_buildProgressBar(demande),
],
),
),
);
}
Widget _buildInfoGeneralesSection(DemandeAide demande) {
return UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Informations générales',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
_buildInfoRow('Type d\'aide', demande.typeAide.libelle, Icons.category),
_buildInfoRow('Priorité', demande.priorite.libelle, Icons.priority_high),
_buildInfoRow('Demandeur', demande.nomDemandeur, Icons.person),
if (demande.montantDemande != null)
_buildInfoRow(
'Montant demandé',
CurrencyFormatter.formatCFA(demande.montantDemande!),
Icons.attach_money,
),
if (demande.montantApprouve != null)
_buildInfoRow(
'Montant approuvé',
CurrencyFormatter.formatCFA(demande.montantApprouve!),
Icons.check_circle,
),
_buildInfoRow(
'Date de création',
DateFormatter.formatComplete(demande.dateCreation),
Icons.calendar_today,
),
if (demande.dateModification != demande.dateCreation)
_buildInfoRow(
'Dernière modification',
DateFormatter.formatComplete(demande.dateModification),
Icons.update,
),
if (demande.dateEcheance != null)
_buildInfoRow(
'Date d\'échéance',
DateFormatter.formatComplete(demande.dateEcheance!),
Icons.schedule,
),
],
),
),
);
}
Widget _buildDescriptionSection(DemandeAide demande) {
return UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Description',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
demande.description,
style: AppTextStyles.bodyMedium,
),
if (demande.justification != null) ...[
const SizedBox(height: 16),
Text(
'Justification',
style: AppTextStyles.titleSmall.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
demande.justification!,
style: AppTextStyles.bodyMedium,
),
],
],
),
),
);
}
Widget _buildBeneficiaireSection(DemandeAide demande) {
if (demande.beneficiaires.isEmpty) return const SizedBox.shrink();
return UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bénéficiaires',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
...demande.beneficiaires.map((beneficiaire) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Icon(
Icons.person,
size: 20,
color: AppColors.primary,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${beneficiaire.prenom} ${beneficiaire.nom}',
style: AppTextStyles.bodyMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
if (beneficiaire.age != null)
Text(
'${beneficiaire.age} ans',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
],
),
)),
],
),
),
);
}
Widget _buildContactUrgenceSection(DemandeAide demande) {
if (demande.contactUrgence == null) return const SizedBox.shrink();
final contact = demande.contactUrgence!;
return UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Contact d\'urgence',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
_buildInfoRow('Nom', '${contact.prenom} ${contact.nom}', Icons.person),
_buildInfoRow('Téléphone', contact.telephone, Icons.phone),
if (contact.email != null)
_buildInfoRow('Email', contact.email!, Icons.email),
_buildInfoRow('Relation', contact.relation, Icons.family_restroom),
],
),
),
);
}
Widget _buildLocalisationSection(DemandeAide demande) {
if (demande.localisation == null) return const SizedBox.shrink();
final localisation = demande.localisation!;
return UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Localisation',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
onPressed: () => _ouvrirCarte(localisation),
icon: const Icon(Icons.map),
tooltip: 'Voir sur la carte',
),
],
),
const SizedBox(height: 12),
_buildInfoRow('Adresse', localisation.adresse, Icons.location_on),
if (localisation.ville != null)
_buildInfoRow('Ville', localisation.ville!, Icons.location_city),
if (localisation.codePostal != null)
_buildInfoRow('Code postal', localisation.codePostal!, Icons.markunread_mailbox),
if (localisation.pays != null)
_buildInfoRow('Pays', localisation.pays!, Icons.flag),
],
),
),
);
}
Widget _buildInfoRow(String label, String value, IconData icon) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 20,
color: AppColors.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
value,
style: AppTextStyles.bodyMedium,
),
],
),
),
],
),
);
}
Widget _buildStatutChip(StatutAide statut) {
final color = _getStatutColor(statut);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: Text(
statut.libelle,
style: AppTextStyles.labelMedium.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildProgressBar(DemandeAide demande) {
final progress = demande.pourcentageAvancement;
final color = _getProgressColor(progress);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Avancement',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
Text(
'${progress.toInt()}%',
style: AppTextStyles.bodySmall.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: progress / 100,
backgroundColor: AppColors.outline,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
],
);
}
Color _getStatutColor(StatutAide statut) {
switch (statut) {
case StatutAide.brouillon:
return AppColors.textSecondary;
case StatutAide.soumise:
return AppColors.warning;
case StatutAide.enEvaluation:
return AppColors.info;
case StatutAide.approuvee:
return AppColors.success;
case StatutAide.rejetee:
return AppColors.error;
case StatutAide.enCours:
return AppColors.primary;
case StatutAide.terminee:
return AppColors.success;
case StatutAide.versee:
return AppColors.success;
case StatutAide.livree:
return AppColors.success;
case StatutAide.annulee:
return AppColors.error;
}
}
Color _getProgressColor(double progress) {
if (progress < 25) return AppColors.error;
if (progress < 50) return AppColors.warning;
if (progress < 75) return AppColors.info;
return AppColors.success;
}
void _rechargerDemande() {
context.read<DemandesAideBloc>().add(
ObtenirDemandeAideEvent(demandeId: widget.demandeId),
);
}
void _onMenuSelected(String value, DemandeAide demande) {
switch (value) {
case 'edit':
_modifierDemande(demande);
break;
case 'submit':
_soumettredemande(demande);
break;
case 'evaluate':
_evaluerDemande(demande);
break;
case 'share':
_partagerDemande(demande);
break;
case 'export':
_exporterDemande(demande);
break;
case 'delete':
_supprimerDemande(demande);
break;
}
}
void _modifierDemande(DemandeAide demande) {
Navigator.pushNamed(
context,
'/solidarite/demandes/modifier',
arguments: demande,
);
}
void _soumettredemande(DemandeAide demande) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Soumettre la demande'),
content: const Text(
'Êtes-vous sûr de vouloir soumettre cette demande d\'aide ? '
'Une fois soumise, elle ne pourra plus être modifiée.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
context.read<DemandesAideBloc>().add(
SoumettreDemandeAideEvent(demandeId: demande.id),
);
},
child: const Text('Soumettre'),
),
],
),
);
}
void _evaluerDemande(DemandeAide demande) {
Navigator.pushNamed(
context,
'/solidarite/demandes/evaluer',
arguments: demande,
);
}
void _partagerDemande(DemandeAide demande) {
// Implémenter le partage
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fonctionnalité de partage à implémenter')),
);
}
void _exporterDemande(DemandeAide demande) {
// Implémenter l'export
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fonctionnalité d\'export à implémenter')),
);
}
void _supprimerDemande(DemandeAide demande) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer la demande'),
content: const Text(
'Êtes-vous sûr de vouloir supprimer cette demande d\'aide ? '
'Cette action est irréversible.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(context); // Retour à la liste
context.read<DemandesAideBloc>().add(
SupprimerDemandesSelectionnees(demandeIds: [demande.id]),
);
},
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
child: const Text('Supprimer'),
),
],
),
);
}
void _ouvrirCarte(Localisation localisation) {
// Implémenter l'ouverture de la carte
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ouverture de la carte à implémenter')),
);
}
}

View File

@@ -0,0 +1,601 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/widgets/unified_page_layout.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/utils/validators.dart';
import '../../domain/entities/demande_aide.dart';
import '../bloc/demandes_aide/demandes_aide_bloc.dart';
import '../bloc/demandes_aide/demandes_aide_event.dart';
import '../bloc/demandes_aide/demandes_aide_state.dart';
import '../widgets/demande_aide_form_sections.dart';
/// Page de formulaire pour créer ou modifier une demande d'aide
///
/// Cette page utilise un formulaire multi-sections avec validation
/// pour créer ou modifier une demande d'aide.
class DemandeAideFormPage extends StatefulWidget {
final DemandeAide? demandeExistante;
final bool isModification;
const DemandeAideFormPage({
super.key,
this.demandeExistante,
this.isModification = false,
});
@override
State<DemandeAideFormPage> createState() => _DemandeAideFormPageState();
}
class _DemandeAideFormPageState extends State<DemandeAideFormPage> {
final _formKey = GlobalKey<FormState>();
final _pageController = PageController();
// Controllers pour les champs de texte
final _titreController = TextEditingController();
final _descriptionController = TextEditingController();
final _justificationController = TextEditingController();
final _montantController = TextEditingController();
// Variables d'état du formulaire
TypeAide? _typeAide;
PrioriteAide _priorite = PrioriteAide.normale;
bool _estUrgente = false;
DateTime? _dateEcheance;
List<BeneficiaireAide> _beneficiaires = [];
ContactUrgence? _contactUrgence;
Localisation? _localisation;
List<PieceJustificative> _piecesJustificatives = [];
int _currentStep = 0;
final int _totalSteps = 5;
bool _isLoading = false;
@override
void initState() {
super.initState();
_initializeForm();
}
@override
void dispose() {
_titreController.dispose();
_descriptionController.dispose();
_justificationController.dispose();
_montantController.dispose();
_pageController.dispose();
super.dispose();
}
void _initializeForm() {
if (widget.demandeExistante != null) {
final demande = widget.demandeExistante!;
_titreController.text = demande.titre;
_descriptionController.text = demande.description;
_justificationController.text = demande.justification ?? '';
_montantController.text = demande.montantDemande?.toString() ?? '';
_typeAide = demande.typeAide;
_priorite = demande.priorite;
_estUrgente = demande.estUrgente;
_dateEcheance = demande.dateEcheance;
_beneficiaires = List.from(demande.beneficiaires);
_contactUrgence = demande.contactUrgence;
_localisation = demande.localisation;
_piecesJustificatives = List.from(demande.piecesJustificatives);
}
}
@override
Widget build(BuildContext context) {
return BlocConsumer<DemandesAideBloc, DemandesAideState>(
listener: (context, state) {
if (state is DemandesAideLoading) {
setState(() {
_isLoading = true;
});
} else {
setState(() {
_isLoading = false;
});
}
if (state is DemandesAideError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.error,
),
);
} else if (state is DemandesAideOperationSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.success,
),
);
Navigator.pop(context, true);
} else if (state is DemandesAideValidation) {
if (!state.isValid) {
_showValidationErrors(state.erreurs);
}
}
},
builder: (context, state) {
return UnifiedPageLayout(
title: widget.isModification ? 'Modifier la demande' : 'Nouvelle demande',
actions: [
if (_currentStep > 0)
IconButton(
onPressed: _previousStep,
icon: const Icon(Icons.arrow_back),
tooltip: 'Étape précédente',
),
IconButton(
onPressed: _saveDraft,
icon: const Icon(Icons.save),
tooltip: 'Sauvegarder le brouillon',
),
],
body: Column(
children: [
_buildProgressIndicator(),
Expanded(
child: Form(
key: _formKey,
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildStep1InfoGenerales(),
_buildStep2Beneficiaires(),
_buildStep3Contact(),
_buildStep4Localisation(),
_buildStep5Documents(),
],
),
),
),
_buildBottomActions(),
],
),
);
},
);
}
Widget _buildProgressIndicator() {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: List.generate(_totalSteps, (index) {
final isActive = index == _currentStep;
final isCompleted = index < _currentStep;
return Expanded(
child: Container(
height: 4,
margin: EdgeInsets.only(right: index < _totalSteps - 1 ? 8 : 0),
decoration: BoxDecoration(
color: isCompleted || isActive
? AppColors.primary
: AppColors.outline,
borderRadius: BorderRadius.circular(2),
),
),
);
}),
),
const SizedBox(height: 8),
Text(
'Étape ${_currentStep + 1} sur $_totalSteps: ${_getStepTitle(_currentStep)}',
style: AppTextStyles.bodyMedium.copyWith(
color: AppColors.textSecondary,
),
),
],
),
);
}
Widget _buildStep1InfoGenerales() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Informations générales',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _titreController,
decoration: const InputDecoration(
labelText: 'Titre de la demande *',
hintText: 'Ex: Aide pour frais médicaux',
border: OutlineInputBorder(),
),
validator: Validators.required,
maxLength: 100,
),
const SizedBox(height: 16),
DropdownButtonFormField<TypeAide>(
value: _typeAide,
decoration: const InputDecoration(
labelText: 'Type d\'aide *',
border: OutlineInputBorder(),
),
items: TypeAide.values.map((type) => DropdownMenuItem(
value: type,
child: Text(type.libelle),
)).toList(),
onChanged: (value) {
setState(() {
_typeAide = value;
});
},
validator: (value) => value == null ? 'Veuillez sélectionner un type d\'aide' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description détaillée *',
hintText: 'Décrivez votre situation et vos besoins...',
border: OutlineInputBorder(),
),
maxLines: 4,
validator: Validators.required,
maxLength: 1000,
),
const SizedBox(height: 16),
TextFormField(
controller: _justificationController,
decoration: const InputDecoration(
labelText: 'Justification',
hintText: 'Pourquoi cette aide est-elle nécessaire ?',
border: OutlineInputBorder(),
),
maxLines: 3,
maxLength: 500,
),
],
),
),
),
const SizedBox(height: 16),
UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Détails de la demande',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _montantController,
decoration: const InputDecoration(
labelText: 'Montant demandé (FCFA)',
hintText: '0',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.attach_money),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value != null && value.isNotEmpty) {
final montant = double.tryParse(value);
if (montant == null || montant <= 0) {
return 'Veuillez saisir un montant valide';
}
}
return null;
},
),
const SizedBox(height: 16),
DropdownButtonFormField<PrioriteAide>(
value: _priorite,
decoration: const InputDecoration(
labelText: 'Priorité',
border: OutlineInputBorder(),
),
items: PrioriteAide.values.map((priorite) => DropdownMenuItem(
value: priorite,
child: Text(priorite.libelle),
)).toList(),
onChanged: (value) {
setState(() {
_priorite = value ?? PrioriteAide.normale;
});
},
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Demande urgente'),
subtitle: const Text('Cette demande nécessite un traitement prioritaire'),
value: _estUrgente,
onChanged: (value) {
setState(() {
_estUrgente = value;
});
},
),
const SizedBox(height: 16),
ListTile(
title: const Text('Date d\'échéance'),
subtitle: Text(_dateEcheance != null
? '${_dateEcheance!.day}/${_dateEcheance!.month}/${_dateEcheance!.year}'
: 'Aucune date limite'),
trailing: const Icon(Icons.calendar_today),
onTap: _selectDateEcheance,
),
],
),
),
),
],
),
);
}
Widget _buildStep2Beneficiaires() {
return DemandeAideFormBeneficiairesSection(
beneficiaires: _beneficiaires,
onBeneficiairesChanged: (beneficiaires) {
setState(() {
_beneficiaires = beneficiaires;
});
},
);
}
Widget _buildStep3Contact() {
return DemandeAideFormContactSection(
contactUrgence: _contactUrgence,
onContactChanged: (contact) {
setState(() {
_contactUrgence = contact;
});
},
);
}
Widget _buildStep4Localisation() {
return DemandeAideFormLocalisationSection(
localisation: _localisation,
onLocalisationChanged: (localisation) {
setState(() {
_localisation = localisation;
});
},
);
}
Widget _buildStep5Documents() {
return DemandeAideFormDocumentsSection(
piecesJustificatives: _piecesJustificatives,
onDocumentsChanged: (documents) {
setState(() {
_piecesJustificatives = documents;
});
},
);
}
Widget _buildBottomActions() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
if (_currentStep > 0)
Expanded(
child: OutlinedButton(
onPressed: _isLoading ? null : _previousStep,
child: const Text('Précédent'),
),
),
if (_currentStep > 0) const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _isLoading ? null : _nextStepOrSubmit,
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(_currentStep < _totalSteps - 1 ? 'Suivant' : 'Créer la demande'),
),
),
],
),
);
}
String _getStepTitle(int step) {
switch (step) {
case 0:
return 'Informations générales';
case 1:
return 'Bénéficiaires';
case 2:
return 'Contact d\'urgence';
case 3:
return 'Localisation';
case 4:
return 'Documents';
default:
return '';
}
}
void _previousStep() {
if (_currentStep > 0) {
setState(() {
_currentStep--;
});
_pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
void _nextStepOrSubmit() {
if (_validateCurrentStep()) {
if (_currentStep < _totalSteps - 1) {
setState(() {
_currentStep++;
});
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
_submitForm();
}
}
}
bool _validateCurrentStep() {
switch (_currentStep) {
case 0:
return _formKey.currentState?.validate() ?? false;
case 1:
// Validation des bénéficiaires (optionnel)
return true;
case 2:
// Validation du contact d'urgence (optionnel)
return true;
case 3:
// Validation de la localisation (optionnel)
return true;
case 4:
// Validation des documents (optionnel)
return true;
default:
return true;
}
}
void _submitForm() {
if (!_formKey.currentState!.validate()) {
return;
}
final demande = DemandeAide(
id: widget.demandeExistante?.id ?? '',
numeroReference: widget.demandeExistante?.numeroReference ?? '',
titre: _titreController.text,
description: _descriptionController.text,
justification: _justificationController.text.isEmpty ? null : _justificationController.text,
typeAide: _typeAide!,
statut: widget.demandeExistante?.statut ?? StatutAide.brouillon,
priorite: _priorite,
estUrgente: _estUrgente,
montantDemande: _montantController.text.isEmpty ? null : double.tryParse(_montantController.text),
montantApprouve: widget.demandeExistante?.montantApprouve,
dateCreation: widget.demandeExistante?.dateCreation ?? DateTime.now(),
dateModification: DateTime.now(),
dateEcheance: _dateEcheance,
organisationId: widget.demandeExistante?.organisationId ?? '',
demandeurId: widget.demandeExistante?.demandeurId ?? '',
nomDemandeur: widget.demandeExistante?.nomDemandeur ?? '',
emailDemandeur: widget.demandeExistante?.emailDemandeur ?? '',
telephoneDemandeur: widget.demandeExistante?.telephoneDemandeur ?? '',
beneficiaires: _beneficiaires,
contactUrgence: _contactUrgence,
localisation: _localisation,
piecesJustificatives: _piecesJustificatives,
evaluations: widget.demandeExistante?.evaluations ?? [],
commentairesInternes: widget.demandeExistante?.commentairesInternes ?? [],
historiqueStatuts: widget.demandeExistante?.historiqueStatuts ?? [],
tags: widget.demandeExistante?.tags ?? [],
metadonnees: widget.demandeExistante?.metadonnees ?? {},
);
if (widget.isModification) {
context.read<DemandesAideBloc>().add(
MettreAJourDemandeAideEvent(demande: demande),
);
} else {
context.read<DemandesAideBloc>().add(
CreerDemandeAideEvent(demande: demande),
);
}
}
void _saveDraft() {
// Sauvegarder le brouillon
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Brouillon sauvegardé'),
backgroundColor: AppColors.success,
),
);
}
void _selectDateEcheance() async {
final date = await showDatePicker(
context: context,
initialDate: _dateEcheance ?? DateTime.now().add(const Duration(days: 30)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (date != null) {
setState(() {
_dateEcheance = date;
});
}
}
void _showValidationErrors(Map<String, String> erreurs) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Erreurs de validation'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: erreurs.entries.map((entry) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text('${entry.value}'),
)).toList(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
}
}

View File

@@ -0,0 +1,676 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/widgets/unified_page_layout.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/widgets/unified_list_widget.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/utils/date_formatter.dart';
import '../../../../core/utils/currency_formatter.dart';
import '../../domain/entities/demande_aide.dart';
import '../bloc/demandes_aide/demandes_aide_bloc.dart';
import '../bloc/demandes_aide/demandes_aide_event.dart';
import '../bloc/demandes_aide/demandes_aide_state.dart';
import '../widgets/demande_aide_card.dart';
import '../widgets/demandes_aide_filter_bottom_sheet.dart';
import '../widgets/demandes_aide_sort_bottom_sheet.dart';
/// Page principale pour afficher la liste des demandes d'aide
///
/// Cette page utilise le pattern BLoC pour gérer l'état et affiche
/// une liste paginée des demandes d'aide avec des fonctionnalités
/// de filtrage, tri, recherche et sélection multiple.
class DemandesAidePage extends StatefulWidget {
final String? organisationId;
final TypeAide? typeAideInitial;
final StatutAide? statutInitial;
const DemandesAidePage({
super.key,
this.organisationId,
this.typeAideInitial,
this.statutInitial,
});
@override
State<DemandesAidePage> createState() => _DemandesAidePageState();
}
class _DemandesAidePageState extends State<DemandesAidePage> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
bool _isSelectionMode = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
// Charger les demandes d'aide au démarrage
context.read<DemandesAideBloc>().add(ChargerDemandesAideEvent(
organisationId: widget.organisationId,
typeAide: widget.typeAideInitial,
statut: widget.statutInitial,
));
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _onScroll() {
if (_isBottom) {
context.read<DemandesAideBloc>().add(const ChargerPlusDemandesAideEvent());
}
}
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<DemandesAideBloc, DemandesAideState>(
listener: (context, state) {
if (state is DemandesAideError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.error,
action: state.canRetry
? SnackBarAction(
label: 'Réessayer',
textColor: Colors.white,
onPressed: () => _rafraichir(),
)
: null,
),
);
} else if (state is DemandesAideOperationSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.success,
),
);
} else if (state is DemandesAideExported) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Fichier exporté: ${state.filePath}'),
backgroundColor: AppColors.success,
action: SnackBarAction(
label: 'Ouvrir',
textColor: Colors.white,
onPressed: () => _ouvrirFichier(state.filePath),
),
),
);
}
},
builder: (context, state) {
return UnifiedPageLayout(
title: 'Demandes d\'aide',
showBackButton: false,
actions: _buildActions(state),
floatingActionButton: _buildFloatingActionButton(),
body: Column(
children: [
_buildSearchBar(state),
_buildFilterChips(state),
Expanded(child: _buildContent(state)),
],
),
);
},
);
}
List<Widget> _buildActions(DemandesAideState state) {
final actions = <Widget>[];
if (_isSelectionMode && state is DemandesAideLoaded) {
// Actions en mode sélection
actions.addAll([
IconButton(
icon: const Icon(Icons.select_all),
onPressed: () => _toggleSelectAll(state),
tooltip: state.toutesDemandesSelectionnees
? 'Désélectionner tout'
: 'Sélectionner tout',
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: state.nombreDemandesSelectionnees > 0
? () => _supprimerSelection(state)
: null,
tooltip: 'Supprimer la sélection',
),
IconButton(
icon: const Icon(Icons.file_download),
onPressed: state.nombreDemandesSelectionnees > 0
? () => _exporterSelection(state)
: null,
tooltip: 'Exporter la sélection',
),
IconButton(
icon: const Icon(Icons.close),
onPressed: _quitterModeSelection,
tooltip: 'Quitter la sélection',
),
]);
} else {
// Actions normales
actions.addAll([
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: () => _afficherFiltres(state),
tooltip: 'Filtrer',
),
IconButton(
icon: const Icon(Icons.sort),
onPressed: () => _afficherTri(state),
tooltip: 'Trier',
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) => _onMenuSelected(value, state),
itemBuilder: (context) => [
const PopupMenuItem(
value: 'refresh',
child: ListTile(
leading: Icon(Icons.refresh),
title: Text('Actualiser'),
dense: true,
),
),
const PopupMenuItem(
value: 'select',
child: ListTile(
leading: Icon(Icons.checklist),
title: Text('Sélection multiple'),
dense: true,
),
),
const PopupMenuItem(
value: 'export_all',
child: ListTile(
leading: Icon(Icons.file_download),
title: Text('Exporter tout'),
dense: true,
),
),
const PopupMenuItem(
value: 'urgentes',
child: ListTile(
leading: Icon(Icons.priority_high, color: AppColors.error),
title: Text('Demandes urgentes'),
dense: true,
),
),
],
),
]);
}
return actions;
}
Widget _buildFloatingActionButton() {
return FloatingActionButton.extended(
onPressed: _creerNouvelleDemande,
icon: const Icon(Icons.add),
label: const Text('Nouvelle demande'),
backgroundColor: AppColors.primary,
);
}
Widget _buildSearchBar(DemandesAideState state) {
return Container(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher des demandes...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_rechercherDemandes('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onChanged: _rechercherDemandes,
onSubmitted: _rechercherDemandes,
),
);
}
Widget _buildFilterChips(DemandesAideState state) {
if (state is! DemandesAideLoaded || !state.hasFiltres) {
return const SizedBox.shrink();
}
return Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ListView(
scrollDirection: Axis.horizontal,
children: [
if (state.filtres.typeAide != null)
_buildFilterChip(
'Type: ${state.filtres.typeAide!.libelle}',
() => _supprimerFiltre('typeAide'),
),
if (state.filtres.statut != null)
_buildFilterChip(
'Statut: ${state.filtres.statut!.libelle}',
() => _supprimerFiltre('statut'),
),
if (state.filtres.priorite != null)
_buildFilterChip(
'Priorité: ${state.filtres.priorite!.libelle}',
() => _supprimerFiltre('priorite'),
),
if (state.filtres.urgente == true)
_buildFilterChip(
'Urgente',
() => _supprimerFiltre('urgente'),
),
if (state.filtres.motCle != null && state.filtres.motCle!.isNotEmpty)
_buildFilterChip(
'Recherche: "${state.filtres.motCle}"',
() => _supprimerFiltre('motCle'),
),
ActionChip(
label: const Text('Effacer tout'),
onPressed: _effacerTousFiltres,
backgroundColor: AppColors.error.withOpacity(0.1),
labelStyle: TextStyle(color: AppColors.error),
),
],
),
);
}
Widget _buildFilterChip(String label, VoidCallback onDeleted) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
label: Text(label),
onDeleted: onDeleted,
backgroundColor: AppColors.primary.withOpacity(0.1),
labelStyle: TextStyle(color: AppColors.primary),
),
);
}
Widget _buildContent(DemandesAideState state) {
if (state is DemandesAideInitial) {
return const Center(
child: Text('Appuyez sur actualiser pour charger les demandes'),
);
}
if (state is DemandesAideLoading && state.isRefreshing == false) {
return const Center(child: CircularProgressIndicator());
}
if (state is DemandesAideError && !state.hasCachedData) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
state.isNetworkError ? Icons.wifi_off : Icons.error,
size: 64,
color: AppColors.error,
),
const SizedBox(height: 16),
Text(
state.message,
style: AppTextStyles.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (state.canRetry)
ElevatedButton(
onPressed: _rafraichir,
child: const Text('Réessayer'),
),
],
),
);
}
if (state is DemandesAideExporting) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(value: state.progress),
const SizedBox(height: 16),
Text(
state.currentStep ?? 'Export en cours...',
style: AppTextStyles.bodyLarge,
),
const SizedBox(height: 8),
Text(
'${(state.progress * 100).toInt()}%',
style: AppTextStyles.bodyMedium,
),
],
),
);
}
if (state is DemandesAideLoaded) {
return _buildDemandesList(state);
}
return const SizedBox.shrink();
}
Widget _buildDemandesList(DemandesAideLoaded state) {
if (state.demandesFiltrees.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox,
size: 64,
color: AppColors.textSecondary,
),
const SizedBox(height: 16),
Text(
state.hasData
? 'Aucun résultat pour les filtres appliqués'
: 'Aucune demande d\'aide',
style: AppTextStyles.bodyLarge,
textAlign: TextAlign.center,
),
if (state.hasFiltres) ...[
const SizedBox(height: 8),
TextButton(
onPressed: _effacerTousFiltres,
child: const Text('Effacer les filtres'),
),
],
],
),
);
}
return RefreshIndicator(
onRefresh: () async => _rafraichir(),
child: UnifiedListWidget<DemandeAide>(
items: state.demandesFiltrees,
itemBuilder: (context, demande, index) => DemandeAideCard(
demande: demande,
isSelected: state.demandesSelectionnees[demande.id] == true,
isSelectionMode: _isSelectionMode,
onTap: () => _onDemandeAideTap(demande),
onLongPress: () => _onDemandeAideLongPress(demande),
onSelectionChanged: (selected) => _onDemandeAideSelectionChanged(demande.id, selected),
),
scrollController: _scrollController,
hasReachedMax: state.hasReachedMax,
isLoading: state.isLoadingMore,
emptyWidget: const SizedBox.shrink(), // Géré plus haut
),
);
}
// Méthodes d'action
void _rafraichir() {
context.read<DemandesAideBloc>().add(const RafraichirDemandesAideEvent());
}
void _rechercherDemandes(String query) {
context.read<DemandesAideBloc>().add(FiltrerDemandesAideEvent(motCle: query));
}
void _afficherFiltres(DemandesAideState state) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => DemandesAideFilterBottomSheet(
filtresActuels: state is DemandesAideLoaded ? state.filtres : const FiltresDemandesAide(),
onFiltresChanged: (filtres) {
context.read<DemandesAideBloc>().add(FiltrerDemandesAideEvent(
typeAide: filtres.typeAide,
statut: filtres.statut,
priorite: filtres.priorite,
urgente: filtres.urgente,
motCle: filtres.motCle,
));
},
),
);
}
void _afficherTri(DemandesAideState state) {
showModalBottomSheet(
context: context,
builder: (context) => DemandesAideSortBottomSheet(
critereActuel: state is DemandesAideLoaded ? state.criterieTri : null,
croissantActuel: state is DemandesAideLoaded ? state.triCroissant : true,
onTriChanged: (critere, croissant) {
context.read<DemandesAideBloc>().add(TrierDemandesAideEvent(
critere: critere,
croissant: croissant,
));
},
),
);
}
void _onMenuSelected(String value, DemandesAideState state) {
switch (value) {
case 'refresh':
_rafraichir();
break;
case 'select':
_activerModeSelection();
break;
case 'export_all':
if (state is DemandesAideLoaded) {
_exporterTout(state);
}
break;
case 'urgentes':
_afficherDemandesUrgentes();
break;
}
}
void _creerNouvelleDemande() {
Navigator.pushNamed(context, '/solidarite/demandes/creer');
}
void _onDemandeAideTap(DemandeAide demande) {
if (_isSelectionMode) {
_onDemandeAideSelectionChanged(
demande.id,
!(context.read<DemandesAideBloc>().state as DemandesAideLoaded)
.demandesSelectionnees[demande.id] == true,
);
} else {
Navigator.pushNamed(
context,
'/solidarite/demandes/details',
arguments: demande.id,
);
}
}
void _onDemandeAideLongPress(DemandeAide demande) {
if (!_isSelectionMode) {
_activerModeSelection();
_onDemandeAideSelectionChanged(demande.id, true);
}
}
void _onDemandeAideSelectionChanged(String demandeId, bool selected) {
context.read<DemandesAideBloc>().add(SelectionnerDemandeAideEvent(
demandeId: demandeId,
selectionne: selected,
));
}
void _activerModeSelection() {
setState(() {
_isSelectionMode = true;
});
}
void _quitterModeSelection() {
setState(() {
_isSelectionMode = false;
});
context.read<DemandesAideBloc>().add(const SelectionnerToutesDemandesAideEvent(selectionne: false));
}
void _toggleSelectAll(DemandesAideLoaded state) {
context.read<DemandesAideBloc>().add(SelectionnerToutesDemandesAideEvent(
selectionne: !state.toutesDemandesSelectionnees,
));
}
void _supprimerSelection(DemandesAideLoaded state) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Êtes-vous sûr de vouloir supprimer ${state.nombreDemandesSelectionnees} demande(s) d\'aide ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
context.read<DemandesAideBloc>().add(SupprimerDemandesSelectionnees(
demandeIds: state.demandesSelectionneesIds,
));
_quitterModeSelection();
},
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
child: const Text('Supprimer'),
),
],
),
);
}
void _exporterSelection(DemandesAideLoaded state) {
_afficherDialogueExport(state.demandesSelectionneesIds);
}
void _exporterTout(DemandesAideLoaded state) {
_afficherDialogueExport(state.demandesFiltrees.map((d) => d.id).toList());
}
void _afficherDialogueExport(List<String> demandeIds) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exporter les demandes'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: FormatExport.values.map((format) => ListTile(
leading: Icon(_getFormatIcon(format)),
title: Text(format.libelle),
onTap: () {
Navigator.pop(context);
context.read<DemandesAideBloc>().add(ExporterDemandesAideEvent(
demandeIds: demandeIds,
format: format,
));
},
)).toList(),
),
),
);
}
IconData _getFormatIcon(FormatExport format) {
switch (format) {
case FormatExport.pdf:
return Icons.picture_as_pdf;
case FormatExport.excel:
return Icons.table_chart;
case FormatExport.csv:
return Icons.grid_on;
case FormatExport.json:
return Icons.code;
}
}
void _afficherDemandesUrgentes() {
context.read<DemandesAideBloc>().add(ChargerDemandesUrgentesEvent(
organisationId: widget.organisationId ?? '',
));
}
void _supprimerFiltre(String filtre) {
final state = context.read<DemandesAideBloc>().state;
if (state is DemandesAideLoaded) {
var nouveauxFiltres = state.filtres;
switch (filtre) {
case 'typeAide':
nouveauxFiltres = nouveauxFiltres.copyWith(typeAide: null);
break;
case 'statut':
nouveauxFiltres = nouveauxFiltres.copyWith(statut: null);
break;
case 'priorite':
nouveauxFiltres = nouveauxFiltres.copyWith(priorite: null);
break;
case 'urgente':
nouveauxFiltres = nouveauxFiltres.copyWith(urgente: null);
break;
case 'motCle':
nouveauxFiltres = nouveauxFiltres.copyWith(motCle: '');
_searchController.clear();
break;
}
context.read<DemandesAideBloc>().add(FiltrerDemandesAideEvent(
typeAide: nouveauxFiltres.typeAide,
statut: nouveauxFiltres.statut,
priorite: nouveauxFiltres.priorite,
urgente: nouveauxFiltres.urgente,
motCle: nouveauxFiltres.motCle,
));
}
}
void _effacerTousFiltres() {
_searchController.clear();
context.read<DemandesAideBloc>().add(const FiltrerDemandesAideEvent());
}
void _ouvrirFichier(String filePath) {
// Implémenter l'ouverture du fichier avec un package comme open_file
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ouverture du fichier: $filePath')),
);
}
}

View File

@@ -0,0 +1,407 @@
import 'package:flutter/material.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/utils/date_formatter.dart';
import '../../../../core/utils/currency_formatter.dart';
import '../../domain/entities/demande_aide.dart';
/// Widget de carte pour afficher une demande d'aide
///
/// Cette carte affiche les informations essentielles d'une demande d'aide
/// avec un design cohérent et des interactions tactiles.
class DemandeAideCard extends StatelessWidget {
final DemandeAide demande;
final bool isSelected;
final bool isSelectionMode;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final ValueChanged<bool>? onSelectionChanged;
const DemandeAideCard({
super.key,
required this.demande,
this.isSelected = false,
this.isSelectionMode = false,
this.onTap,
this.onLongPress,
this.onSelectionChanged,
});
@override
Widget build(BuildContext context) {
return UnifiedCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: isSelected
? Border.all(color: AppColors.primary, width: 2)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 12),
_buildContent(),
const SizedBox(height: 12),
_buildFooter(),
],
),
),
),
);
}
Widget _buildHeader() {
return Row(
children: [
if (isSelectionMode) ...[
Checkbox(
value: isSelected,
onChanged: onSelectionChanged,
activeColor: AppColors.primary,
),
const SizedBox(width: 8),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
demande.titre,
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_buildStatutChip(),
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.person,
size: 16,
color: AppColors.textSecondary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
demande.nomDemandeur,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
demande.numeroReference,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
fontFamily: 'monospace',
),
),
],
),
],
),
),
if (demande.estUrgente) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.priority_high,
size: 16,
color: AppColors.error,
),
const SizedBox(width: 4),
Text(
'URGENT',
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.error,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
],
);
}
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
demande.description,
style: AppTextStyles.bodyMedium,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
_buildTypeAideChip(),
const SizedBox(width: 8),
_buildPrioriteChip(),
const Spacer(),
if (demande.montantDemande != null)
Text(
CurrencyFormatter.formatCFA(demande.montantDemande!),
style: AppTextStyles.titleSmall.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.bold,
),
),
],
),
],
);
}
Widget _buildFooter() {
return Row(
children: [
Icon(
Icons.access_time,
size: 16,
color: AppColors.textSecondary,
),
const SizedBox(width: 4),
Text(
'Créée ${DateFormatter.formatRelative(demande.dateCreation)}',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
if (demande.dateModification != demande.dateCreation) ...[
const SizedBox(width: 8),
Text(
'• Modifiée ${DateFormatter.formatRelative(demande.dateModification)}',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
],
const Spacer(),
_buildProgressIndicator(),
],
);
}
Widget _buildStatutChip() {
final color = _getStatutColor(demande.statut);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
demande.statut.libelle,
style: AppTextStyles.labelSmall.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildTypeAideChip() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.outline),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getTypeAideIcon(demande.typeAide),
size: 14,
color: AppColors.primary,
),
const SizedBox(width: 4),
Text(
demande.typeAide.libelle,
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.textPrimary,
),
),
],
),
);
}
Widget _buildPrioriteChip() {
final color = _getPrioriteColor(demande.priorite);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getPrioriteIcon(demande.priorite),
size: 14,
color: color,
),
const SizedBox(width: 4),
Text(
demande.priorite.libelle,
style: AppTextStyles.labelSmall.copyWith(
color: color,
),
),
],
),
);
}
Widget _buildProgressIndicator() {
final progress = demande.pourcentageAvancement;
final color = _getProgressColor(progress);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 60,
height: 4,
decoration: BoxDecoration(
color: AppColors.outline,
borderRadius: BorderRadius.circular(2),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: progress / 100,
child: Container(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
),
),
const SizedBox(width: 8),
Text(
'${progress.toInt()}%',
style: AppTextStyles.labelSmall.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
],
);
}
Color _getStatutColor(StatutAide statut) {
switch (statut) {
case StatutAide.brouillon:
return AppColors.textSecondary;
case StatutAide.soumise:
return AppColors.warning;
case StatutAide.enEvaluation:
return AppColors.info;
case StatutAide.approuvee:
return AppColors.success;
case StatutAide.rejetee:
return AppColors.error;
case StatutAide.enCours:
return AppColors.primary;
case StatutAide.terminee:
return AppColors.success;
case StatutAide.versee:
return AppColors.success;
case StatutAide.livree:
return AppColors.success;
case StatutAide.annulee:
return AppColors.error;
}
}
Color _getPrioriteColor(PrioriteAide priorite) {
switch (priorite) {
case PrioriteAide.basse:
return AppColors.success;
case PrioriteAide.normale:
return AppColors.info;
case PrioriteAide.haute:
return AppColors.warning;
case PrioriteAide.critique:
return AppColors.error;
}
}
Color _getProgressColor(double progress) {
if (progress < 25) return AppColors.error;
if (progress < 50) return AppColors.warning;
if (progress < 75) return AppColors.info;
return AppColors.success;
}
IconData _getTypeAideIcon(TypeAide typeAide) {
switch (typeAide) {
case TypeAide.aideFinanciereUrgente:
return Icons.attach_money;
case TypeAide.aideFinanciereMedicale:
return Icons.medical_services;
case TypeAide.aideFinanciereEducation:
return Icons.school;
case TypeAide.aideMaterielleVetements:
return Icons.checkroom;
case TypeAide.aideMaterielleNourriture:
return Icons.restaurant;
case TypeAide.aideProfessionnelleFormation:
return Icons.work;
case TypeAide.aideSocialeAccompagnement:
return Icons.support;
case TypeAide.autre:
return Icons.help;
}
}
IconData _getPrioriteIcon(PrioriteAide priorite) {
switch (priorite) {
case PrioriteAide.basse:
return Icons.keyboard_arrow_down;
case PrioriteAide.normale:
return Icons.remove;
case PrioriteAide.haute:
return Icons.keyboard_arrow_up;
case PrioriteAide.critique:
return Icons.priority_high;
}
}
}

View File

@@ -0,0 +1,343 @@
import 'package:flutter/material.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/utils/file_utils.dart';
import '../../domain/entities/demande_aide.dart';
/// Widget pour afficher la section des documents d'une demande d'aide
///
/// Ce widget affiche tous les documents joints à une demande d'aide
/// avec la possibilité de les visualiser et télécharger.
class DemandeAideDocumentsSection extends StatelessWidget {
final DemandeAide demande;
const DemandeAideDocumentsSection({
super.key,
required this.demande,
});
@override
Widget build(BuildContext context) {
if (demande.piecesJustificatives.isEmpty) {
return const SizedBox.shrink();
}
return UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Documents joints',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${demande.piecesJustificatives.length}',
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 16),
...demande.piecesJustificatives.asMap().entries.map((entry) {
final index = entry.key;
final document = entry.value;
final isLast = index == demande.piecesJustificatives.length - 1;
return Column(
children: [
_buildDocumentCard(context, document),
if (!isLast) const SizedBox(height: 8),
],
);
}),
],
),
),
);
}
Widget _buildDocumentCard(BuildContext context, PieceJustificative document) {
final fileExtension = _getFileExtension(document.nomFichier);
final fileIcon = _getFileIcon(fileExtension);
final fileColor = _getFileColor(fileExtension);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.outline),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: fileColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
fileIcon,
color: fileColor,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
document.nomFichier,
style: AppTextStyles.bodyMedium.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Text(
document.typeDocument.libelle,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
if (document.tailleFichier != null) ...[
Text(
'',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
Text(
_formatFileSize(document.tailleFichier!),
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
],
],
),
if (document.description != null && document.description!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
document.description!,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
fontStyle: FontStyle.italic,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: 8),
Column(
children: [
IconButton(
onPressed: () => _previewDocument(context, document),
icon: const Icon(Icons.visibility),
tooltip: 'Aperçu',
iconSize: 20,
),
IconButton(
onPressed: () => _downloadDocument(context, document),
icon: const Icon(Icons.download),
tooltip: 'Télécharger',
iconSize: 20,
),
],
),
],
),
);
}
String _getFileExtension(String fileName) {
final parts = fileName.split('.');
return parts.length > 1 ? parts.last.toLowerCase() : '';
}
IconData _getFileIcon(String extension) {
switch (extension) {
case 'pdf':
return Icons.picture_as_pdf;
case 'doc':
case 'docx':
return Icons.description;
case 'xls':
case 'xlsx':
return Icons.table_chart;
case 'ppt':
case 'pptx':
return Icons.slideshow;
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'bmp':
return Icons.image;
case 'mp4':
case 'avi':
case 'mov':
case 'wmv':
return Icons.video_file;
case 'mp3':
case 'wav':
case 'aac':
return Icons.audio_file;
case 'zip':
case 'rar':
case '7z':
return Icons.archive;
case 'txt':
return Icons.text_snippet;
default:
return Icons.insert_drive_file;
}
}
Color _getFileColor(String extension) {
switch (extension) {
case 'pdf':
return Colors.red;
case 'doc':
case 'docx':
return Colors.blue;
case 'xls':
case 'xlsx':
return Colors.green;
case 'ppt':
case 'pptx':
return Colors.orange;
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'bmp':
return Colors.purple;
case 'mp4':
case 'avi':
case 'mov':
case 'wmv':
return Colors.indigo;
case 'mp3':
case 'wav':
case 'aac':
return Colors.teal;
case 'zip':
case 'rar':
case '7z':
return Colors.brown;
case 'txt':
return Colors.grey;
default:
return AppColors.textSecondary;
}
}
String _formatFileSize(int bytes) {
if (bytes < 1024) {
return '$bytes B';
} else if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
} else if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
}
void _previewDocument(BuildContext context, PieceJustificative document) {
// Implémenter la prévisualisation du document
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Aperçu du document'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Nom: ${document.nomFichier}'),
Text('Type: ${document.typeDocument.libelle}'),
if (document.tailleFichier != null)
Text('Taille: ${_formatFileSize(document.tailleFichier!)}'),
if (document.description != null && document.description!.isNotEmpty)
Text('Description: ${document.description}'),
const SizedBox(height: 16),
const Text('Fonctionnalité de prévisualisation à implémenter'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
_downloadDocument(context, document);
},
child: const Text('Télécharger'),
),
],
),
);
}
void _downloadDocument(BuildContext context, PieceJustificative document) {
// Implémenter le téléchargement du document
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Téléchargement de ${document.nomFichier}...'),
action: SnackBarAction(
label: 'Annuler',
onPressed: () {
// Annuler le téléchargement
},
),
),
);
// Simuler le téléchargement
Future.delayed(const Duration(seconds: 2), () {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${document.nomFichier} téléchargé avec succès'),
backgroundColor: AppColors.success,
action: SnackBarAction(
label: 'Ouvrir',
textColor: Colors.white,
onPressed: () {
// Ouvrir le fichier téléchargé
},
),
),
);
}
});
}
}

View File

@@ -0,0 +1,412 @@
import 'package:flutter/material.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/utils/date_formatter.dart';
import '../../../../core/utils/currency_formatter.dart';
import '../../domain/entities/demande_aide.dart';
/// Widget pour afficher la section des évaluations d'une demande d'aide
///
/// Ce widget affiche toutes les évaluations effectuées sur une demande d'aide
/// avec les détails de chaque évaluation.
class DemandeAideEvaluationSection extends StatelessWidget {
final DemandeAide demande;
const DemandeAideEvaluationSection({
super.key,
required this.demande,
});
@override
Widget build(BuildContext context) {
if (demande.evaluations.isEmpty) {
return const SizedBox.shrink();
}
return UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Évaluations',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${demande.evaluations.length}',
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 16),
...demande.evaluations.asMap().entries.map((entry) {
final index = entry.key;
final evaluation = entry.value;
final isLast = index == demande.evaluations.length - 1;
return Column(
children: [
_buildEvaluationCard(evaluation),
if (!isLast) const SizedBox(height: 12),
],
);
}),
],
),
),
);
}
Widget _buildEvaluationCard(EvaluationAide evaluation) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.outline),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildEvaluationHeader(evaluation),
const SizedBox(height: 12),
_buildEvaluationContent(evaluation),
if (evaluation.commentaire != null && evaluation.commentaire!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildCommentaireSection(evaluation.commentaire!),
],
if (evaluation.criteres.isNotEmpty) ...[
const SizedBox(height: 12),
_buildCriteresSection(evaluation.criteres),
],
],
),
);
}
Widget _buildEvaluationHeader(EvaluationAide evaluation) {
final color = _getDecisionColor(evaluation.decision);
return Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: color.withOpacity(0.1),
child: Icon(
_getDecisionIcon(evaluation.decision),
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
evaluation.nomEvaluateur,
style: AppTextStyles.bodyMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
evaluation.typeEvaluateur.libelle,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
evaluation.decision.libelle,
style: AppTextStyles.labelSmall.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 4),
Text(
DateFormatter.formatShort(evaluation.dateEvaluation),
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
],
),
],
);
}
Widget _buildEvaluationContent(EvaluationAide evaluation) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (evaluation.noteGlobale != null) ...[
Row(
children: [
Text(
'Note globale:',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
_buildStarRating(evaluation.noteGlobale!),
const SizedBox(width: 8),
Text(
'${evaluation.noteGlobale!.toStringAsFixed(1)}/5',
style: AppTextStyles.bodySmall.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
],
if (evaluation.montantRecommande != null) ...[
Row(
children: [
Icon(
Icons.attach_money,
size: 16,
color: AppColors.textSecondary,
),
const SizedBox(width: 4),
Text(
'Montant recommandé:',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
Text(
CurrencyFormatter.formatCFA(evaluation.montantRecommande!),
style: AppTextStyles.bodyMedium.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
],
if (evaluation.prioriteRecommandee != null) ...[
Row(
children: [
Icon(
Icons.priority_high,
size: 16,
color: AppColors.textSecondary,
),
const SizedBox(width: 4),
Text(
'Priorité recommandée:',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _getPrioriteColor(evaluation.prioriteRecommandee!).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
evaluation.prioriteRecommandee!.libelle,
style: AppTextStyles.labelSmall.copyWith(
color: _getPrioriteColor(evaluation.prioriteRecommandee!),
fontWeight: FontWeight.w600,
),
),
),
],
),
],
],
);
}
Widget _buildCommentaireSection(String commentaire) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.outline),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.comment,
size: 16,
color: AppColors.textSecondary,
),
const SizedBox(width: 4),
Text(
'Commentaire',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
Text(
commentaire,
style: AppTextStyles.bodySmall,
),
],
),
);
}
Widget _buildCriteresSection(Map<String, double> criteres) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.outline),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.checklist,
size: 16,
color: AppColors.textSecondary,
),
const SizedBox(width: 4),
Text(
'Critères d\'évaluation',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
...criteres.entries.map((entry) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Expanded(
child: Text(
entry.key,
style: AppTextStyles.bodySmall,
),
),
const SizedBox(width: 8),
_buildStarRating(entry.value),
const SizedBox(width: 8),
Text(
'${entry.value.toStringAsFixed(1)}/5',
style: AppTextStyles.bodySmall.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
)),
],
),
);
}
Widget _buildStarRating(double rating) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (index) {
final starValue = index + 1;
return Icon(
starValue <= rating
? Icons.star
: starValue - 0.5 <= rating
? Icons.star_half
: Icons.star_border,
size: 16,
color: AppColors.warning,
);
}),
);
}
Color _getDecisionColor(StatutAide decision) {
switch (decision) {
case StatutAide.approuvee:
return AppColors.success;
case StatutAide.rejetee:
return AppColors.error;
case StatutAide.enEvaluation:
return AppColors.info;
default:
return AppColors.textSecondary;
}
}
IconData _getDecisionIcon(StatutAide decision) {
switch (decision) {
case StatutAide.approuvee:
return Icons.check_circle;
case StatutAide.rejetee:
return Icons.cancel;
case StatutAide.enEvaluation:
return Icons.rate_review;
default:
return Icons.help;
}
}
Color _getPrioriteColor(PrioriteAide priorite) {
switch (priorite) {
case PrioriteAide.basse:
return AppColors.success;
case PrioriteAide.normale:
return AppColors.info;
case PrioriteAide.haute:
return AppColors.warning;
case PrioriteAide.critique:
return AppColors.error;
}
}
}

View File

@@ -0,0 +1,744 @@
import 'package:flutter/material.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/utils/validators.dart';
import '../../domain/entities/demande_aide.dart';
/// Section du formulaire pour les bénéficiaires
class DemandeAideFormBeneficiairesSection extends StatefulWidget {
final List<BeneficiaireAide> beneficiaires;
final ValueChanged<List<BeneficiaireAide>> onBeneficiairesChanged;
const DemandeAideFormBeneficiairesSection({
super.key,
required this.beneficiaires,
required this.onBeneficiairesChanged,
});
@override
State<DemandeAideFormBeneficiairesSection> createState() => _DemandeAideFormBeneficiairesState();
}
class _DemandeAideFormBeneficiairesState extends State<DemandeAideFormBeneficiairesSection> {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Bénéficiaires',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton.icon(
onPressed: _ajouterBeneficiaire,
icon: const Icon(Icons.add),
label: const Text('Ajouter'),
),
],
),
const SizedBox(height: 8),
Text(
'Ajoutez les personnes qui bénéficieront de cette aide (optionnel)',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
if (widget.beneficiaires.isEmpty)
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.outline),
),
child: Column(
children: [
Icon(
Icons.people_outline,
size: 48,
color: AppColors.textSecondary,
),
const SizedBox(height: 8),
Text(
'Aucun bénéficiaire ajouté',
style: AppTextStyles.bodyMedium.copyWith(
color: AppColors.textSecondary,
),
),
],
),
)
else
...widget.beneficiaires.asMap().entries.map((entry) {
final index = entry.key;
final beneficiaire = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildBeneficiaireCard(beneficiaire, index),
);
}),
],
),
),
),
],
),
);
}
Widget _buildBeneficiaireCard(BeneficiaireAide beneficiaire, int index) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.outline),
),
child: Row(
children: [
CircleAvatar(
backgroundColor: AppColors.primary.withOpacity(0.1),
child: Icon(
Icons.person,
color: AppColors.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${beneficiaire.prenom} ${beneficiaire.nom}',
style: AppTextStyles.bodyMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
if (beneficiaire.age != null)
Text(
'${beneficiaire.age} ans',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
IconButton(
onPressed: () => _modifierBeneficiaire(index),
icon: const Icon(Icons.edit),
iconSize: 20,
),
IconButton(
onPressed: () => _supprimerBeneficiaire(index),
icon: const Icon(Icons.delete),
iconSize: 20,
color: AppColors.error,
),
],
),
);
}
void _ajouterBeneficiaire() {
_showBeneficiaireDialog();
}
void _modifierBeneficiaire(int index) {
_showBeneficiaireDialog(beneficiaire: widget.beneficiaires[index], index: index);
}
void _supprimerBeneficiaire(int index) {
final nouveauxBeneficiaires = List<BeneficiaireAide>.from(widget.beneficiaires);
nouveauxBeneficiaires.removeAt(index);
widget.onBeneficiairesChanged(nouveauxBeneficiaires);
}
void _showBeneficiaireDialog({BeneficiaireAide? beneficiaire, int? index}) {
final prenomController = TextEditingController(text: beneficiaire?.prenom ?? '');
final nomController = TextEditingController(text: beneficiaire?.nom ?? '');
final ageController = TextEditingController(text: beneficiaire?.age?.toString() ?? '');
final formKey = GlobalKey<FormState>();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(beneficiaire == null ? 'Ajouter un bénéficiaire' : 'Modifier le bénéficiaire'),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: prenomController,
decoration: const InputDecoration(
labelText: 'Prénom *',
border: OutlineInputBorder(),
),
validator: Validators.required,
),
const SizedBox(height: 16),
TextFormField(
controller: nomController,
decoration: const InputDecoration(
labelText: 'Nom *',
border: OutlineInputBorder(),
),
validator: Validators.required,
),
const SizedBox(height: 16),
TextFormField(
controller: ageController,
decoration: const InputDecoration(
labelText: 'Âge',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value != null && value.isNotEmpty) {
final age = int.tryParse(value);
if (age == null || age < 0 || age > 150) {
return 'Veuillez saisir un âge valide';
}
}
return null;
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
if (formKey.currentState!.validate()) {
final nouveauBeneficiaire = BeneficiaireAide(
prenom: prenomController.text,
nom: nomController.text,
age: ageController.text.isEmpty ? null : int.parse(ageController.text),
);
final nouveauxBeneficiaires = List<BeneficiaireAide>.from(widget.beneficiaires);
if (index != null) {
nouveauxBeneficiaires[index] = nouveauBeneficiaire;
} else {
nouveauxBeneficiaires.add(nouveauBeneficiaire);
}
widget.onBeneficiairesChanged(nouveauxBeneficiaires);
Navigator.pop(context);
}
},
child: Text(beneficiaire == null ? 'Ajouter' : 'Modifier'),
),
],
),
);
}
}
/// Section du formulaire pour le contact d'urgence
class DemandeAideFormContactSection extends StatefulWidget {
final ContactUrgence? contactUrgence;
final ValueChanged<ContactUrgence?> onContactChanged;
const DemandeAideFormContactSection({
super.key,
required this.contactUrgence,
required this.onContactChanged,
});
@override
State<DemandeAideFormContactSection> createState() => _DemandeAideFormContactSectionState();
}
class _DemandeAideFormContactSectionState extends State<DemandeAideFormContactSection> {
final _prenomController = TextEditingController();
final _nomController = TextEditingController();
final _telephoneController = TextEditingController();
final _emailController = TextEditingController();
final _relationController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
if (widget.contactUrgence != null) {
_prenomController.text = widget.contactUrgence!.prenom;
_nomController.text = widget.contactUrgence!.nom;
_telephoneController.text = widget.contactUrgence!.telephone;
_emailController.text = widget.contactUrgence!.email ?? '';
_relationController.text = widget.contactUrgence!.relation;
}
}
@override
void dispose() {
_prenomController.dispose();
_nomController.dispose();
_telephoneController.dispose();
_emailController.dispose();
_relationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Contact d\'urgence',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Personne à contacter en cas d\'urgence (optionnel)',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _prenomController,
decoration: const InputDecoration(
labelText: 'Prénom',
border: OutlineInputBorder(),
),
onChanged: _updateContact,
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _nomController,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
onChanged: _updateContact,
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _telephoneController,
decoration: const InputDecoration(
labelText: 'Téléphone',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
),
keyboardType: TextInputType.phone,
onChanged: _updateContact,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
onChanged: _updateContact,
),
const SizedBox(height: 16),
TextFormField(
controller: _relationController,
decoration: const InputDecoration(
labelText: 'Relation',
hintText: 'Ex: Conjoint, Parent, Ami...',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.family_restroom),
),
onChanged: _updateContact,
),
],
),
),
),
),
],
),
);
}
void _updateContact(String value) {
if (_prenomController.text.isNotEmpty ||
_nomController.text.isNotEmpty ||
_telephoneController.text.isNotEmpty ||
_emailController.text.isNotEmpty ||
_relationController.text.isNotEmpty) {
final contact = ContactUrgence(
prenom: _prenomController.text,
nom: _nomController.text,
telephone: _telephoneController.text,
email: _emailController.text.isEmpty ? null : _emailController.text,
relation: _relationController.text,
);
widget.onContactChanged(contact);
} else {
widget.onContactChanged(null);
}
}
}
/// Section du formulaire pour la localisation
class DemandeAideFormLocalisationSection extends StatefulWidget {
final Localisation? localisation;
final ValueChanged<Localisation?> onLocalisationChanged;
const DemandeAideFormLocalisationSection({
super.key,
required this.localisation,
required this.onLocalisationChanged,
});
@override
State<DemandeAideFormLocalisationSection> createState() => _DemandeAideFormLocalisationSectionState();
}
class _DemandeAideFormLocalisationSectionState extends State<DemandeAideFormLocalisationSection> {
final _adresseController = TextEditingController();
final _villeController = TextEditingController();
final _codePostalController = TextEditingController();
final _paysController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
if (widget.localisation != null) {
_adresseController.text = widget.localisation!.adresse;
_villeController.text = widget.localisation!.ville ?? '';
_codePostalController.text = widget.localisation!.codePostal ?? '';
_paysController.text = widget.localisation!.pays ?? '';
}
}
@override
void dispose() {
_adresseController.dispose();
_villeController.dispose();
_codePostalController.dispose();
_paysController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Localisation',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Lieu où l\'aide sera fournie (optionnel)',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _adresseController,
decoration: const InputDecoration(
labelText: 'Adresse',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_on),
),
maxLines: 2,
onChanged: _updateLocalisation,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _villeController,
decoration: const InputDecoration(
labelText: 'Ville',
border: OutlineInputBorder(),
),
onChanged: _updateLocalisation,
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _codePostalController,
decoration: const InputDecoration(
labelText: 'Code postal',
border: OutlineInputBorder(),
),
onChanged: _updateLocalisation,
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _paysController,
decoration: const InputDecoration(
labelText: 'Pays',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.flag),
),
onChanged: _updateLocalisation,
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _utiliserPositionActuelle,
icon: const Icon(Icons.my_location),
label: const Text('Utiliser ma position actuelle'),
),
],
),
),
),
),
],
),
);
}
void _updateLocalisation(String value) {
if (_adresseController.text.isNotEmpty ||
_villeController.text.isNotEmpty ||
_codePostalController.text.isNotEmpty ||
_paysController.text.isNotEmpty) {
final localisation = Localisation(
adresse: _adresseController.text,
ville: _villeController.text.isEmpty ? null : _villeController.text,
codePostal: _codePostalController.text.isEmpty ? null : _codePostalController.text,
pays: _paysController.text.isEmpty ? null : _paysController.text,
latitude: widget.localisation?.latitude,
longitude: widget.localisation?.longitude,
);
widget.onLocalisationChanged(localisation);
} else {
widget.onLocalisationChanged(null);
}
}
void _utiliserPositionActuelle() {
// Implémenter la géolocalisation
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité de géolocalisation à implémenter'),
),
);
}
}
/// Section du formulaire pour les documents
class DemandeAideFormDocumentsSection extends StatefulWidget {
final List<PieceJustificative> piecesJustificatives;
final ValueChanged<List<PieceJustificative>> onDocumentsChanged;
const DemandeAideFormDocumentsSection({
super.key,
required this.piecesJustificatives,
required this.onDocumentsChanged,
});
@override
State<DemandeAideFormDocumentsSection> createState() => _DemandeAideFormDocumentsSectionState();
}
class _DemandeAideFormDocumentsSectionState extends State<DemandeAideFormDocumentsSection> {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Documents justificatifs',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton.icon(
onPressed: _ajouterDocument,
icon: const Icon(Icons.add),
label: const Text('Ajouter'),
),
],
),
const SizedBox(height: 8),
Text(
'Ajoutez des documents pour appuyer votre demande (optionnel)',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
if (widget.piecesJustificatives.isEmpty)
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.outline),
),
child: Column(
children: [
Icon(
Icons.upload_file,
size: 48,
color: AppColors.textSecondary,
),
const SizedBox(height: 8),
Text(
'Aucun document ajouté',
style: AppTextStyles.bodyMedium.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
Text(
'Formats acceptés: PDF, DOC, JPG, PNG',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
],
),
)
else
...widget.piecesJustificatives.asMap().entries.map((entry) {
final index = entry.key;
final document = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildDocumentCard(document, index),
);
}),
],
),
),
),
],
),
);
}
Widget _buildDocumentCard(PieceJustificative document, int index) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.outline),
),
child: Row(
children: [
Icon(
Icons.insert_drive_file,
color: AppColors.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
document.nomFichier,
style: AppTextStyles.bodyMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
document.typeDocument.libelle,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
IconButton(
onPressed: () => _supprimerDocument(index),
icon: const Icon(Icons.delete),
iconSize: 20,
color: AppColors.error,
),
],
),
);
}
void _ajouterDocument() {
// Implémenter la sélection de fichier
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité de sélection de fichier à implémenter'),
),
);
}
void _supprimerDocument(int index) {
final nouveauxDocuments = List<PieceJustificative>.from(widget.piecesJustificatives);
nouveauxDocuments.removeAt(index);
widget.onDocumentsChanged(nouveauxDocuments);
}
}

View File

@@ -0,0 +1,308 @@
import 'package:flutter/material.dart';
import '../../../../core/widgets/unified_card.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/utils/date_formatter.dart';
import '../../domain/entities/demande_aide.dart';
/// Widget de timeline pour afficher l'historique des statuts d'une demande d'aide
///
/// Ce widget affiche une timeline verticale avec tous les changements de statut
/// de la demande d'aide, incluant les dates et les commentaires.
class DemandeAideStatusTimeline extends StatelessWidget {
final DemandeAide demande;
const DemandeAideStatusTimeline({
super.key,
required this.demande,
});
@override
Widget build(BuildContext context) {
final historique = _buildHistorique();
if (historique.isEmpty) {
return const SizedBox.shrink();
}
return UnifiedCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Historique des statuts',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
...historique.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final isLast = index == historique.length - 1;
return _buildTimelineItem(
item: item,
isLast: isLast,
isActive: index == 0, // Le premier élément est l'état actuel
);
}),
],
),
),
);
}
List<TimelineItem> _buildHistorique() {
final items = <TimelineItem>[];
// Ajouter l'état actuel
items.add(TimelineItem(
statut: demande.statut,
date: demande.dateModification,
commentaire: _getStatutDescription(demande.statut),
isActuel: true,
));
// Ajouter l'historique depuis les évaluations
for (final evaluation in demande.evaluations) {
items.add(TimelineItem(
statut: evaluation.decision,
date: evaluation.dateEvaluation,
commentaire: evaluation.commentaire,
evaluateur: evaluation.nomEvaluateur,
));
}
// Ajouter la création
if (demande.dateCreation != demande.dateModification) {
items.add(TimelineItem(
statut: StatutAide.brouillon,
date: demande.dateCreation,
commentaire: 'Demande créée',
));
}
return items;
}
Widget _buildTimelineItem({
required TimelineItem item,
required bool isLast,
required bool isActive,
}) {
final color = isActive ? _getStatutColor(item.statut) : AppColors.textSecondary;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline indicator
Column(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isActive ? color : AppColors.surface,
border: Border.all(
color: color,
width: isActive ? 3 : 2,
),
shape: BoxShape.circle,
),
child: isActive
? Icon(
_getStatutIcon(item.statut),
size: 12,
color: Colors.white,
)
: null,
),
if (!isLast)
Container(
width: 2,
height: 40,
color: AppColors.outline,
),
],
),
const SizedBox(width: 16),
// Content
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.statut.libelle,
style: AppTextStyles.bodyMedium.copyWith(
fontWeight: isActive ? FontWeight.bold : FontWeight.w600,
color: isActive ? color : AppColors.textPrimary,
),
),
),
if (item.isActuel)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'ACTUEL',
style: AppTextStyles.labelSmall.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 4),
Text(
DateFormatter.formatComplete(item.date),
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
),
),
if (item.evaluateur != null) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.person,
size: 16,
color: AppColors.textSecondary,
),
const SizedBox(width: 4),
Text(
'Par ${item.evaluateur}',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textSecondary,
fontStyle: FontStyle.italic,
),
),
],
),
],
if (item.commentaire != null && item.commentaire!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.outline),
),
child: Text(
item.commentaire!,
style: AppTextStyles.bodySmall,
),
),
],
],
),
),
),
],
);
}
Color _getStatutColor(StatutAide statut) {
switch (statut) {
case StatutAide.brouillon:
return AppColors.textSecondary;
case StatutAide.soumise:
return AppColors.warning;
case StatutAide.enEvaluation:
return AppColors.info;
case StatutAide.approuvee:
return AppColors.success;
case StatutAide.rejetee:
return AppColors.error;
case StatutAide.enCours:
return AppColors.primary;
case StatutAide.terminee:
return AppColors.success;
case StatutAide.versee:
return AppColors.success;
case StatutAide.livree:
return AppColors.success;
case StatutAide.annulee:
return AppColors.error;
}
}
IconData _getStatutIcon(StatutAide statut) {
switch (statut) {
case StatutAide.brouillon:
return Icons.edit;
case StatutAide.soumise:
return Icons.send;
case StatutAide.enEvaluation:
return Icons.rate_review;
case StatutAide.approuvee:
return Icons.check;
case StatutAide.rejetee:
return Icons.close;
case StatutAide.enCours:
return Icons.play_arrow;
case StatutAide.terminee:
return Icons.done_all;
case StatutAide.versee:
return Icons.payment;
case StatutAide.livree:
return Icons.local_shipping;
case StatutAide.annulee:
return Icons.cancel;
}
}
String _getStatutDescription(StatutAide statut) {
switch (statut) {
case StatutAide.brouillon:
return 'Demande en cours de rédaction';
case StatutAide.soumise:
return 'Demande soumise pour évaluation';
case StatutAide.enEvaluation:
return 'Demande en cours d\'évaluation';
case StatutAide.approuvee:
return 'Demande approuvée';
case StatutAide.rejetee:
return 'Demande rejetée';
case StatutAide.enCours:
return 'Aide en cours de traitement';
case StatutAide.terminee:
return 'Aide terminée';
case StatutAide.versee:
return 'Montant versé';
case StatutAide.livree:
return 'Aide livrée';
case StatutAide.annulee:
return 'Demande annulée';
}
}
}
/// Classe pour représenter un élément de la timeline
class TimelineItem {
final StatutAide statut;
final DateTime date;
final String? commentaire;
final String? evaluateur;
final bool isActuel;
const TimelineItem({
required this.statut,
required this.date,
this.commentaire,
this.evaluateur,
this.isActuel = false,
});
}

View File

@@ -0,0 +1,444 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../domain/entities/demande_aide.dart';
import '../bloc/demandes_aide/demandes_aide_state.dart';
/// Bottom sheet pour filtrer les demandes d'aide
///
/// Permet à l'utilisateur de sélectionner différents critères
/// de filtrage pour affiner la liste des demandes d'aide.
class DemandesAideFilterBottomSheet extends StatefulWidget {
final FiltresDemandesAide filtresActuels;
final ValueChanged<FiltresDemandesAide> onFiltresChanged;
const DemandesAideFilterBottomSheet({
super.key,
required this.filtresActuels,
required this.onFiltresChanged,
});
@override
State<DemandesAideFilterBottomSheet> createState() => _DemandesAideFilterBottomSheetState();
}
class _DemandesAideFilterBottomSheetState extends State<DemandesAideFilterBottomSheet> {
late FiltresDemandesAide _filtres;
final TextEditingController _motCleController = TextEditingController();
final TextEditingController _montantMinController = TextEditingController();
final TextEditingController _montantMaxController = TextEditingController();
@override
void initState() {
super.initState();
_filtres = widget.filtresActuels;
_motCleController.text = _filtres.motCle ?? '';
_montantMinController.text = _filtres.montantMin?.toInt().toString() ?? '';
_montantMaxController.text = _filtres.montantMax?.toInt().toString() ?? '';
}
@override
void dispose() {
_motCleController.dispose();
_montantMinController.dispose();
_montantMaxController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.of(context).size.height * 0.8,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 16),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMotCleSection(),
const SizedBox(height: 24),
_buildTypeAideSection(),
const SizedBox(height: 24),
_buildStatutSection(),
const SizedBox(height: 24),
_buildPrioriteSection(),
const SizedBox(height: 24),
_buildUrgenteSection(),
const SizedBox(height: 24),
_buildMontantSection(),
const SizedBox(height: 24),
_buildDateSection(),
],
),
),
),
const SizedBox(height: 16),
_buildActions(),
],
),
);
}
Widget _buildHeader() {
return Row(
children: [
Text(
'Filtrer les demandes',
style: AppTextStyles.titleLarge.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
);
}
Widget _buildMotCleSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recherche par mot-clé',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
TextField(
controller: _motCleController,
decoration: const InputDecoration(
hintText: 'Titre, description, demandeur...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: (value) {
setState(() {
_filtres = _filtres.copyWith(motCle: value.isEmpty ? null : value);
});
},
),
],
);
}
Widget _buildTypeAideSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Type d\'aide',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildFilterChip(
label: 'Tous',
isSelected: _filtres.typeAide == null,
onSelected: () {
setState(() {
_filtres = _filtres.copyWith(typeAide: null);
});
},
),
...TypeAide.values.map((type) => _buildFilterChip(
label: type.libelle,
isSelected: _filtres.typeAide == type,
onSelected: () {
setState(() {
_filtres = _filtres.copyWith(typeAide: type);
});
},
)),
],
),
],
);
}
Widget _buildStatutSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Statut',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildFilterChip(
label: 'Tous',
isSelected: _filtres.statut == null,
onSelected: () {
setState(() {
_filtres = _filtres.copyWith(statut: null);
});
},
),
...StatutAide.values.map((statut) => _buildFilterChip(
label: statut.libelle,
isSelected: _filtres.statut == statut,
onSelected: () {
setState(() {
_filtres = _filtres.copyWith(statut: statut);
});
},
)),
],
),
],
);
}
Widget _buildPrioriteSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Priorité',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildFilterChip(
label: 'Toutes',
isSelected: _filtres.priorite == null,
onSelected: () {
setState(() {
_filtres = _filtres.copyWith(priorite: null);
});
},
),
...PrioriteAide.values.map((priorite) => _buildFilterChip(
label: priorite.libelle,
isSelected: _filtres.priorite == priorite,
onSelected: () {
setState(() {
_filtres = _filtres.copyWith(priorite: priorite);
});
},
)),
],
),
],
);
}
Widget _buildUrgenteSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Urgence',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: CheckboxListTile(
title: const Text('Demandes urgentes uniquement'),
value: _filtres.urgente == true,
onChanged: (value) {
setState(() {
_filtres = _filtres.copyWith(urgente: value == true ? true : null);
});
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
),
],
),
],
);
}
Widget _buildMontantSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Montant demandé (FCFA)',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _montantMinController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Minimum',
border: OutlineInputBorder(),
),
onChanged: (value) {
final montant = double.tryParse(value);
setState(() {
_filtres = _filtres.copyWith(montantMin: montant);
});
},
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _montantMaxController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Maximum',
border: OutlineInputBorder(),
),
onChanged: (value) {
final montant = double.tryParse(value);
setState(() {
_filtres = _filtres.copyWith(montantMax: montant);
});
},
),
),
],
),
],
);
}
Widget _buildDateSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Période de création',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _selectDate(context, true),
icon: const Icon(Icons.calendar_today),
label: Text(
_filtres.dateDebutCreation != null
? '${_filtres.dateDebutCreation!.day}/${_filtres.dateDebutCreation!.month}/${_filtres.dateDebutCreation!.year}'
: 'Date début',
),
),
),
const SizedBox(width: 16),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _selectDate(context, false),
icon: const Icon(Icons.calendar_today),
label: Text(
_filtres.dateFinCreation != null
? '${_filtres.dateFinCreation!.day}/${_filtres.dateFinCreation!.month}/${_filtres.dateFinCreation!.year}'
: 'Date fin',
),
),
),
],
),
],
);
}
Widget _buildFilterChip({
required String label,
required bool isSelected,
required VoidCallback onSelected,
}) {
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (_) => onSelected(),
selectedColor: AppColors.primary.withOpacity(0.2),
checkmarkColor: AppColors.primary,
);
}
Widget _buildActions() {
return Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _reinitialiserFiltres,
child: const Text('Réinitialiser'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _appliquerFiltres,
child: Text('Appliquer (${_filtres.nombreFiltresActifs})'),
),
),
],
);
}
Future<void> _selectDate(BuildContext context, bool isStartDate) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: isStartDate
? _filtres.dateDebutCreation ?? DateTime.now()
: _filtres.dateFinCreation ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() {
if (isStartDate) {
_filtres = _filtres.copyWith(dateDebutCreation: picked);
} else {
_filtres = _filtres.copyWith(dateFinCreation: picked);
}
});
}
}
void _reinitialiserFiltres() {
setState(() {
_filtres = const FiltresDemandesAide();
_motCleController.clear();
_montantMinController.clear();
_montantMaxController.clear();
});
}
void _appliquerFiltres() {
widget.onFiltresChanged(_filtres);
Navigator.pop(context);
}
}

View File

@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../bloc/demandes_aide/demandes_aide_event.dart';
/// Bottom sheet pour trier les demandes d'aide
///
/// Permet à l'utilisateur de sélectionner un critère de tri
/// et l'ordre (croissant/décroissant) pour la liste des demandes.
class DemandesAideSortBottomSheet extends StatefulWidget {
final TriDemandes? critereActuel;
final bool croissantActuel;
final Function(TriDemandes critere, bool croissant) onTriChanged;
const DemandesAideSortBottomSheet({
super.key,
this.critereActuel,
required this.croissantActuel,
required this.onTriChanged,
});
@override
State<DemandesAideSortBottomSheet> createState() => _DemandesAideSortBottomSheetState();
}
class _DemandesAideSortBottomSheetState extends State<DemandesAideSortBottomSheet> {
late TriDemandes? _critereSelectionne;
late bool _croissant;
@override
void initState() {
super.initState();
_critereSelectionne = widget.critereActuel;
_croissant = widget.croissantActuel;
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 16),
_buildCriteresList(),
const SizedBox(height: 16),
_buildOrdreSection(),
const SizedBox(height: 24),
_buildActions(),
],
),
);
}
Widget _buildHeader() {
return Row(
children: [
Text(
'Trier les demandes',
style: AppTextStyles.titleLarge.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
);
}
Widget _buildCriteresList() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Critère de tri',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
...TriDemandes.values.map((critere) => _buildCritereItem(critere)),
],
);
}
Widget _buildCritereItem(TriDemandes critere) {
final isSelected = _critereSelectionne == critere;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
elevation: isSelected ? 2 : 0,
color: isSelected ? AppColors.primary.withOpacity(0.1) : null,
child: ListTile(
leading: Icon(
_getCritereIcon(critere),
color: isSelected ? AppColors.primary : AppColors.textSecondary,
),
title: Text(
critere.libelle,
style: AppTextStyles.bodyLarge.copyWith(
color: isSelected ? AppColors.primary : AppColors.textPrimary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
subtitle: Text(
_getCritereDescription(critere),
style: AppTextStyles.bodySmall.copyWith(
color: isSelected ? AppColors.primary : AppColors.textSecondary,
),
),
trailing: isSelected
? Icon(
Icons.check_circle,
color: AppColors.primary,
)
: null,
onTap: () {
setState(() {
_critereSelectionne = critere;
});
},
),
);
}
Widget _buildOrdreSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ordre de tri',
style: AppTextStyles.titleMedium.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Card(
elevation: _croissant ? 2 : 0,
color: _croissant ? AppColors.primary.withOpacity(0.1) : null,
child: ListTile(
leading: Icon(
Icons.arrow_upward,
color: _croissant ? AppColors.primary : AppColors.textSecondary,
),
title: Text(
'Croissant',
style: AppTextStyles.bodyMedium.copyWith(
color: _croissant ? AppColors.primary : AppColors.textPrimary,
fontWeight: _croissant ? FontWeight.w600 : FontWeight.normal,
),
),
subtitle: Text(
_getOrdreDescription(true),
style: AppTextStyles.bodySmall.copyWith(
color: _croissant ? AppColors.primary : AppColors.textSecondary,
),
),
trailing: _croissant
? Icon(
Icons.check_circle,
color: AppColors.primary,
)
: null,
onTap: () {
setState(() {
_croissant = true;
});
},
),
),
),
const SizedBox(width: 8),
Expanded(
child: Card(
elevation: !_croissant ? 2 : 0,
color: !_croissant ? AppColors.primary.withOpacity(0.1) : null,
child: ListTile(
leading: Icon(
Icons.arrow_downward,
color: !_croissant ? AppColors.primary : AppColors.textSecondary,
),
title: Text(
'Décroissant',
style: AppTextStyles.bodyMedium.copyWith(
color: !_croissant ? AppColors.primary : AppColors.textPrimary,
fontWeight: !_croissant ? FontWeight.w600 : FontWeight.normal,
),
),
subtitle: Text(
_getOrdreDescription(false),
style: AppTextStyles.bodySmall.copyWith(
color: !_croissant ? AppColors.primary : AppColors.textSecondary,
),
),
trailing: !_croissant
? Icon(
Icons.check_circle,
color: AppColors.primary,
)
: null,
onTap: () {
setState(() {
_croissant = false;
});
},
),
),
),
],
),
],
);
}
Widget _buildActions() {
return Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _reinitialiserTri,
child: const Text('Réinitialiser'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _critereSelectionne != null ? _appliquerTri : null,
child: const Text('Appliquer'),
),
),
],
);
}
IconData _getCritereIcon(TriDemandes critere) {
switch (critere) {
case TriDemandes.dateCreation:
return Icons.calendar_today;
case TriDemandes.dateModification:
return Icons.update;
case TriDemandes.titre:
return Icons.title;
case TriDemandes.statut:
return Icons.flag;
case TriDemandes.priorite:
return Icons.priority_high;
case TriDemandes.montant:
return Icons.attach_money;
case TriDemandes.demandeur:
return Icons.person;
}
}
String _getCritereDescription(TriDemandes critere) {
switch (critere) {
case TriDemandes.dateCreation:
return 'Trier par date de création de la demande';
case TriDemandes.dateModification:
return 'Trier par date de dernière modification';
case TriDemandes.titre:
return 'Trier par titre de la demande (alphabétique)';
case TriDemandes.statut:
return 'Trier par statut de la demande';
case TriDemandes.priorite:
return 'Trier par niveau de priorité';
case TriDemandes.montant:
return 'Trier par montant demandé';
case TriDemandes.demandeur:
return 'Trier par nom du demandeur (alphabétique)';
}
}
String _getOrdreDescription(bool croissant) {
if (_critereSelectionne == null) return '';
switch (_critereSelectionne!) {
case TriDemandes.dateCreation:
case TriDemandes.dateModification:
return croissant ? 'Plus ancien en premier' : 'Plus récent en premier';
case TriDemandes.titre:
case TriDemandes.demandeur:
return croissant ? 'A à Z' : 'Z à A';
case TriDemandes.statut:
return croissant ? 'Brouillon à Terminée' : 'Terminée à Brouillon';
case TriDemandes.priorite:
return croissant ? 'Basse à Critique' : 'Critique à Basse';
case TriDemandes.montant:
return croissant ? 'Montant le plus faible' : 'Montant le plus élevé';
}
}
void _reinitialiserTri() {
setState(() {
_critereSelectionne = null;
_croissant = true;
});
}
void _appliquerTri() {
if (_critereSelectionne != null) {
widget.onTriChanged(_critereSelectionne!, _croissant);
Navigator.pop(context);
}
}
}