Version propre - Dashboard enhanced
This commit is contained in:
3
unionflow-mobile-apps/devtools_options.yaml
Normal file
3
unionflow-mobile-apps/devtools_options.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'core/auth/bloc/auth_bloc.dart';
|
import 'core/auth/bloc/temp_auth_bloc.dart';
|
||||||
import 'core/auth/models/auth_state.dart';
|
import 'core/auth/models/auth_state.dart';
|
||||||
import 'features/splash/presentation/pages/splash_screen.dart';
|
import 'features/splash/presentation/pages/splash_screen.dart';
|
||||||
import 'features/auth/presentation/pages/login_page.dart';
|
import 'features/auth/presentation/pages/login_page.dart';
|
||||||
@@ -12,7 +12,7 @@ class AppWrapper extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<AuthBloc, AuthState>(
|
return BlocBuilder<TempAuthBloc, AuthState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
switch (state.status) {
|
switch (state.status) {
|
||||||
case AuthStatus.unknown:
|
case AuthStatus.unknown:
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'core/auth/bloc/temp_auth_bloc.dart';
|
|
||||||
import 'core/auth/models/auth_state.dart';
|
|
||||||
import 'features/splash/presentation/pages/splash_screen.dart';
|
|
||||||
import 'features/auth/presentation/pages/login_page_temp.dart';
|
|
||||||
import 'features/navigation/presentation/pages/main_navigation.dart';
|
|
||||||
|
|
||||||
/// Wrapper temporaire de l'application
|
|
||||||
class AppTempWrapper extends StatelessWidget {
|
|
||||||
const AppTempWrapper({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocBuilder<TempAuthBloc, AuthState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
switch (state.status) {
|
|
||||||
case AuthStatus.unknown:
|
|
||||||
case AuthStatus.checking:
|
|
||||||
return const SplashScreen();
|
|
||||||
|
|
||||||
case AuthStatus.authenticated:
|
|
||||||
return const MainNavigation();
|
|
||||||
|
|
||||||
case AuthStatus.unauthenticated:
|
|
||||||
case AuthStatus.error:
|
|
||||||
case AuthStatus.expired:
|
|
||||||
return const TempLoginPage();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'main_ultra_simple.dart';
|
|
||||||
import 'core/auth/models/auth_state.dart';
|
|
||||||
import 'features/splash/presentation/pages/splash_screen.dart';
|
|
||||||
import 'features/auth/presentation/pages/login_page_temp.dart';
|
|
||||||
import 'features/navigation/presentation/pages/main_navigation.dart';
|
|
||||||
|
|
||||||
/// Wrapper ultra-simple de l'application
|
|
||||||
class UltraSimpleAppWrapper extends StatelessWidget {
|
|
||||||
const UltraSimpleAppWrapper({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocBuilder<UltraSimpleAuthBloc, AuthState>(
|
|
||||||
builder: (context, state) {
|
|
||||||
switch (state.status) {
|
|
||||||
case AuthStatus.unknown:
|
|
||||||
case AuthStatus.checking:
|
|
||||||
return const SplashScreen();
|
|
||||||
|
|
||||||
case AuthStatus.authenticated:
|
|
||||||
return const MainNavigation();
|
|
||||||
|
|
||||||
case AuthStatus.unauthenticated:
|
|
||||||
case AuthStatus.error:
|
|
||||||
case AuthStatus.expired:
|
|
||||||
return const TempLoginPage();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,12 @@ import 'package:unionflow_mobile_apps/core/network/auth_interceptor.dart'
|
|||||||
as _i772;
|
as _i772;
|
||||||
import 'package:unionflow_mobile_apps/core/network/dio_client.dart' as _i978;
|
import 'package:unionflow_mobile_apps/core/network/dio_client.dart' as _i978;
|
||||||
import 'package:unionflow_mobile_apps/core/services/api_service.dart' as _i238;
|
import 'package:unionflow_mobile_apps/core/services/api_service.dart' as _i238;
|
||||||
|
import 'package:unionflow_mobile_apps/features/cotisations/data/repositories/cotisation_repository_impl.dart'
|
||||||
|
as _i991;
|
||||||
|
import 'package:unionflow_mobile_apps/features/cotisations/domain/repositories/cotisation_repository.dart'
|
||||||
|
as _i961;
|
||||||
|
import 'package:unionflow_mobile_apps/features/cotisations/presentation/bloc/cotisations_bloc.dart'
|
||||||
|
as _i919;
|
||||||
import 'package:unionflow_mobile_apps/features/members/data/repositories/membre_repository_impl.dart'
|
import 'package:unionflow_mobile_apps/features/members/data/repositories/membre_repository_impl.dart'
|
||||||
as _i108;
|
as _i108;
|
||||||
import 'package:unionflow_mobile_apps/features/members/domain/repositories/membre_repository.dart'
|
import 'package:unionflow_mobile_apps/features/members/domain/repositories/membre_repository.dart'
|
||||||
@@ -47,6 +53,8 @@ extension GetItInjectableX on _i174.GetIt {
|
|||||||
() => _i238.ApiService(gh<_i978.DioClient>()));
|
() => _i238.ApiService(gh<_i978.DioClient>()));
|
||||||
gh.singleton<_i772.AuthInterceptor>(
|
gh.singleton<_i772.AuthInterceptor>(
|
||||||
() => _i772.AuthInterceptor(gh<_i394.SecureTokenStorage>()));
|
() => _i772.AuthInterceptor(gh<_i394.SecureTokenStorage>()));
|
||||||
|
gh.lazySingleton<_i961.CotisationRepository>(
|
||||||
|
() => _i991.CotisationRepositoryImpl(gh<_i238.ApiService>()));
|
||||||
gh.lazySingleton<_i930.MembreRepository>(
|
gh.lazySingleton<_i930.MembreRepository>(
|
||||||
() => _i108.MembreRepositoryImpl(gh<_i238.ApiService>()));
|
() => _i108.MembreRepositoryImpl(gh<_i238.ApiService>()));
|
||||||
gh.factory<_i41.MembresBloc>(
|
gh.factory<_i41.MembresBloc>(
|
||||||
@@ -57,6 +65,8 @@ extension GetItInjectableX on _i174.GetIt {
|
|||||||
gh<_i772.AuthInterceptor>(),
|
gh<_i772.AuthInterceptor>(),
|
||||||
gh<_i978.DioClient>(),
|
gh<_i978.DioClient>(),
|
||||||
));
|
));
|
||||||
|
gh.factory<_i919.CotisationsBloc>(
|
||||||
|
() => _i919.CotisationsBloc(gh<_i961.CotisationRepository>()));
|
||||||
gh.singleton<_i635.AuthBloc>(() => _i635.AuthBloc(gh<_i423.AuthService>()));
|
gh.singleton<_i635.AuthBloc>(() => _i635.AuthBloc(gh<_i423.AuthService>()));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|||||||
271
unionflow-mobile-apps/lib/core/models/cotisation_model.dart
Normal file
271
unionflow-mobile-apps/lib/core/models/cotisation_model.dart
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
part 'cotisation_model.g.dart';
|
||||||
|
|
||||||
|
/// Modèle de données pour les cotisations
|
||||||
|
/// Correspond au CotisationDTO du backend
|
||||||
|
@JsonSerializable()
|
||||||
|
class CotisationModel {
|
||||||
|
final String id;
|
||||||
|
final String numeroReference;
|
||||||
|
final String membreId;
|
||||||
|
final String? nomMembre;
|
||||||
|
final String? numeroMembre;
|
||||||
|
final String typeCotisation;
|
||||||
|
final double montantDu;
|
||||||
|
final double montantPaye;
|
||||||
|
final String codeDevise;
|
||||||
|
final String statut;
|
||||||
|
final DateTime dateEcheance;
|
||||||
|
final DateTime? datePaiement;
|
||||||
|
final String? description;
|
||||||
|
final String? periode;
|
||||||
|
final int annee;
|
||||||
|
final int? mois;
|
||||||
|
final String? observations;
|
||||||
|
final bool recurrente;
|
||||||
|
final int nombreRappels;
|
||||||
|
final DateTime? dateDernierRappel;
|
||||||
|
final String? valideParId;
|
||||||
|
final String? nomValidateur;
|
||||||
|
final DateTime? dateValidation;
|
||||||
|
final String? methodePaiement;
|
||||||
|
final String? referencePaiement;
|
||||||
|
final DateTime dateCreation;
|
||||||
|
final DateTime? dateModification;
|
||||||
|
|
||||||
|
const CotisationModel({
|
||||||
|
required this.id,
|
||||||
|
required this.numeroReference,
|
||||||
|
required this.membreId,
|
||||||
|
this.nomMembre,
|
||||||
|
this.numeroMembre,
|
||||||
|
required this.typeCotisation,
|
||||||
|
required this.montantDu,
|
||||||
|
required this.montantPaye,
|
||||||
|
required this.codeDevise,
|
||||||
|
required this.statut,
|
||||||
|
required this.dateEcheance,
|
||||||
|
this.datePaiement,
|
||||||
|
this.description,
|
||||||
|
this.periode,
|
||||||
|
required this.annee,
|
||||||
|
this.mois,
|
||||||
|
this.observations,
|
||||||
|
required this.recurrente,
|
||||||
|
required this.nombreRappels,
|
||||||
|
this.dateDernierRappel,
|
||||||
|
this.valideParId,
|
||||||
|
this.nomValidateur,
|
||||||
|
this.dateValidation,
|
||||||
|
this.methodePaiement,
|
||||||
|
this.referencePaiement,
|
||||||
|
required this.dateCreation,
|
||||||
|
this.dateModification,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Factory pour créer depuis JSON
|
||||||
|
factory CotisationModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CotisationModelFromJson(json);
|
||||||
|
|
||||||
|
/// Convertit vers JSON
|
||||||
|
Map<String, dynamic> toJson() => _$CotisationModelToJson(this);
|
||||||
|
|
||||||
|
/// Calcule le montant restant à payer
|
||||||
|
double get montantRestant => montantDu - montantPaye;
|
||||||
|
|
||||||
|
/// Vérifie si la cotisation est entièrement payée
|
||||||
|
bool get isEntierementPayee => montantRestant <= 0;
|
||||||
|
|
||||||
|
/// Vérifie si la cotisation est en retard
|
||||||
|
bool get isEnRetard {
|
||||||
|
return dateEcheance.isBefore(DateTime.now()) && !isEntierementPayee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le pourcentage de paiement
|
||||||
|
double get pourcentagePaiement {
|
||||||
|
if (montantDu == 0) return 0;
|
||||||
|
return (montantPaye / montantDu * 100).clamp(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la couleur associée au statut
|
||||||
|
String get couleurStatut {
|
||||||
|
switch (statut) {
|
||||||
|
case 'PAYEE':
|
||||||
|
return '#4CAF50'; // Vert
|
||||||
|
case 'EN_ATTENTE':
|
||||||
|
return '#FF9800'; // Orange
|
||||||
|
case 'EN_RETARD':
|
||||||
|
return '#F44336'; // Rouge
|
||||||
|
case 'PARTIELLEMENT_PAYEE':
|
||||||
|
return '#2196F3'; // Bleu
|
||||||
|
case 'ANNULEE':
|
||||||
|
return '#9E9E9E'; // Gris
|
||||||
|
default:
|
||||||
|
return '#757575'; // Gris foncé
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le libellé du statut en français
|
||||||
|
String get libelleStatut {
|
||||||
|
switch (statut) {
|
||||||
|
case 'PAYEE':
|
||||||
|
return 'Payée';
|
||||||
|
case 'EN_ATTENTE':
|
||||||
|
return 'En attente';
|
||||||
|
case 'EN_RETARD':
|
||||||
|
return 'En retard';
|
||||||
|
case 'PARTIELLEMENT_PAYEE':
|
||||||
|
return 'Partiellement payée';
|
||||||
|
case 'ANNULEE':
|
||||||
|
return 'Annulée';
|
||||||
|
default:
|
||||||
|
return statut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le libellé du type de cotisation
|
||||||
|
String get libelleTypeCotisation {
|
||||||
|
switch (typeCotisation) {
|
||||||
|
case 'MENSUELLE':
|
||||||
|
return 'Mensuelle';
|
||||||
|
case 'TRIMESTRIELLE':
|
||||||
|
return 'Trimestrielle';
|
||||||
|
case 'SEMESTRIELLE':
|
||||||
|
return 'Semestrielle';
|
||||||
|
case 'ANNUELLE':
|
||||||
|
return 'Annuelle';
|
||||||
|
case 'EXCEPTIONNELLE':
|
||||||
|
return 'Exceptionnelle';
|
||||||
|
case 'ADHESION':
|
||||||
|
return 'Adhésion';
|
||||||
|
default:
|
||||||
|
return typeCotisation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne l'icône associée au type de cotisation
|
||||||
|
String get iconeTypeCotisation {
|
||||||
|
switch (typeCotisation) {
|
||||||
|
case 'MENSUELLE':
|
||||||
|
return '📅';
|
||||||
|
case 'TRIMESTRIELLE':
|
||||||
|
return '📊';
|
||||||
|
case 'SEMESTRIELLE':
|
||||||
|
return '📈';
|
||||||
|
case 'ANNUELLE':
|
||||||
|
return '🗓️';
|
||||||
|
case 'EXCEPTIONNELLE':
|
||||||
|
return '⚡';
|
||||||
|
case 'ADHESION':
|
||||||
|
return '🎯';
|
||||||
|
default:
|
||||||
|
return '💰';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le nombre de jours jusqu'à l'échéance
|
||||||
|
int get joursJusquEcheance {
|
||||||
|
final maintenant = DateTime.now();
|
||||||
|
final difference = dateEcheance.difference(maintenant);
|
||||||
|
return difference.inDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si l'échéance approche (moins de 7 jours)
|
||||||
|
bool get echeanceProche {
|
||||||
|
return joursJusquEcheance <= 7 && joursJusquEcheance >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne un message d'urgence basé sur l'échéance
|
||||||
|
String get messageUrgence {
|
||||||
|
final jours = joursJusquEcheance;
|
||||||
|
if (jours < 0) {
|
||||||
|
return 'En retard de ${-jours} jour${-jours > 1 ? 's' : ''}';
|
||||||
|
} else if (jours == 0) {
|
||||||
|
return 'Échéance aujourd\'hui';
|
||||||
|
} else if (jours <= 3) {
|
||||||
|
return 'Échéance dans $jours jour${jours > 1 ? 's' : ''}';
|
||||||
|
} else if (jours <= 7) {
|
||||||
|
return 'Échéance dans $jours jours';
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copie avec modifications
|
||||||
|
CotisationModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? numeroReference,
|
||||||
|
String? membreId,
|
||||||
|
String? nomMembre,
|
||||||
|
String? numeroMembre,
|
||||||
|
String? typeCotisation,
|
||||||
|
double? montantDu,
|
||||||
|
double? montantPaye,
|
||||||
|
String? codeDevise,
|
||||||
|
String? statut,
|
||||||
|
DateTime? dateEcheance,
|
||||||
|
DateTime? datePaiement,
|
||||||
|
String? description,
|
||||||
|
String? periode,
|
||||||
|
int? annee,
|
||||||
|
int? mois,
|
||||||
|
String? observations,
|
||||||
|
bool? recurrente,
|
||||||
|
int? nombreRappels,
|
||||||
|
DateTime? dateDernierRappel,
|
||||||
|
String? valideParId,
|
||||||
|
String? nomValidateur,
|
||||||
|
DateTime? dateValidation,
|
||||||
|
String? methodePaiement,
|
||||||
|
String? referencePaiement,
|
||||||
|
DateTime? dateCreation,
|
||||||
|
DateTime? dateModification,
|
||||||
|
}) {
|
||||||
|
return CotisationModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
numeroReference: numeroReference ?? this.numeroReference,
|
||||||
|
membreId: membreId ?? this.membreId,
|
||||||
|
nomMembre: nomMembre ?? this.nomMembre,
|
||||||
|
numeroMembre: numeroMembre ?? this.numeroMembre,
|
||||||
|
typeCotisation: typeCotisation ?? this.typeCotisation,
|
||||||
|
montantDu: montantDu ?? this.montantDu,
|
||||||
|
montantPaye: montantPaye ?? this.montantPaye,
|
||||||
|
codeDevise: codeDevise ?? this.codeDevise,
|
||||||
|
statut: statut ?? this.statut,
|
||||||
|
dateEcheance: dateEcheance ?? this.dateEcheance,
|
||||||
|
datePaiement: datePaiement ?? this.datePaiement,
|
||||||
|
description: description ?? this.description,
|
||||||
|
periode: periode ?? this.periode,
|
||||||
|
annee: annee ?? this.annee,
|
||||||
|
mois: mois ?? this.mois,
|
||||||
|
observations: observations ?? this.observations,
|
||||||
|
recurrente: recurrente ?? this.recurrente,
|
||||||
|
nombreRappels: nombreRappels ?? this.nombreRappels,
|
||||||
|
dateDernierRappel: dateDernierRappel ?? this.dateDernierRappel,
|
||||||
|
valideParId: valideParId ?? this.valideParId,
|
||||||
|
nomValidateur: nomValidateur ?? this.nomValidateur,
|
||||||
|
dateValidation: dateValidation ?? this.dateValidation,
|
||||||
|
methodePaiement: methodePaiement ?? this.methodePaiement,
|
||||||
|
referencePaiement: referencePaiement ?? this.referencePaiement,
|
||||||
|
dateCreation: dateCreation ?? this.dateCreation,
|
||||||
|
dateModification: dateModification ?? this.dateModification,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return other is CotisationModel && other.id == id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => id.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'CotisationModel(id: $id, numeroReference: $numeroReference, '
|
||||||
|
'nomMembre: $nomMembre, typeCotisation: $typeCotisation, '
|
||||||
|
'montantDu: $montantDu, statut: $statut)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'cotisation_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
CotisationModel _$CotisationModelFromJson(Map<String, dynamic> json) =>
|
||||||
|
CotisationModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
numeroReference: json['numeroReference'] as String,
|
||||||
|
membreId: json['membreId'] as String,
|
||||||
|
nomMembre: json['nomMembre'] as String?,
|
||||||
|
numeroMembre: json['numeroMembre'] as String?,
|
||||||
|
typeCotisation: json['typeCotisation'] as String,
|
||||||
|
montantDu: (json['montantDu'] as num).toDouble(),
|
||||||
|
montantPaye: (json['montantPaye'] as num).toDouble(),
|
||||||
|
codeDevise: json['codeDevise'] as String,
|
||||||
|
statut: json['statut'] as String,
|
||||||
|
dateEcheance: DateTime.parse(json['dateEcheance'] as String),
|
||||||
|
datePaiement: json['datePaiement'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['datePaiement'] as String),
|
||||||
|
description: json['description'] as String?,
|
||||||
|
periode: json['periode'] as String?,
|
||||||
|
annee: (json['annee'] as num).toInt(),
|
||||||
|
mois: (json['mois'] as num?)?.toInt(),
|
||||||
|
observations: json['observations'] as String?,
|
||||||
|
recurrente: json['recurrente'] as bool,
|
||||||
|
nombreRappels: (json['nombreRappels'] as num).toInt(),
|
||||||
|
dateDernierRappel: json['dateDernierRappel'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['dateDernierRappel'] as String),
|
||||||
|
valideParId: json['valideParId'] as String?,
|
||||||
|
nomValidateur: json['nomValidateur'] as String?,
|
||||||
|
dateValidation: json['dateValidation'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['dateValidation'] as String),
|
||||||
|
methodePaiement: json['methodePaiement'] as String?,
|
||||||
|
referencePaiement: json['referencePaiement'] as String?,
|
||||||
|
dateCreation: DateTime.parse(json['dateCreation'] as String),
|
||||||
|
dateModification: json['dateModification'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['dateModification'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$CotisationModelToJson(CotisationModel instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'numeroReference': instance.numeroReference,
|
||||||
|
'membreId': instance.membreId,
|
||||||
|
'nomMembre': instance.nomMembre,
|
||||||
|
'numeroMembre': instance.numeroMembre,
|
||||||
|
'typeCotisation': instance.typeCotisation,
|
||||||
|
'montantDu': instance.montantDu,
|
||||||
|
'montantPaye': instance.montantPaye,
|
||||||
|
'codeDevise': instance.codeDevise,
|
||||||
|
'statut': instance.statut,
|
||||||
|
'dateEcheance': instance.dateEcheance.toIso8601String(),
|
||||||
|
'datePaiement': instance.datePaiement?.toIso8601String(),
|
||||||
|
'description': instance.description,
|
||||||
|
'periode': instance.periode,
|
||||||
|
'annee': instance.annee,
|
||||||
|
'mois': instance.mois,
|
||||||
|
'observations': instance.observations,
|
||||||
|
'recurrente': instance.recurrente,
|
||||||
|
'nombreRappels': instance.nombreRappels,
|
||||||
|
'dateDernierRappel': instance.dateDernierRappel?.toIso8601String(),
|
||||||
|
'valideParId': instance.valideParId,
|
||||||
|
'nomValidateur': instance.nomValidateur,
|
||||||
|
'dateValidation': instance.dateValidation?.toIso8601String(),
|
||||||
|
'methodePaiement': instance.methodePaiement,
|
||||||
|
'referencePaiement': instance.referencePaiement,
|
||||||
|
'dateCreation': instance.dateCreation.toIso8601String(),
|
||||||
|
'dateModification': instance.dateModification?.toIso8601String(),
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import '../models/membre_model.dart';
|
import '../models/membre_model.dart';
|
||||||
|
import '../models/cotisation_model.dart';
|
||||||
import '../models/wave_checkout_session_model.dart';
|
import '../models/wave_checkout_session_model.dart';
|
||||||
import '../network/dio_client.dart';
|
import '../network/dio_client.dart';
|
||||||
|
|
||||||
@@ -164,6 +165,191 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// COTISATIONS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/// Récupère la liste de toutes les cotisations avec pagination
|
||||||
|
Future<List<CotisationModel>> getCotisations({int page = 0, int size = 20}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/cotisations', queryParameters: {
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data is List) {
|
||||||
|
return (response.data as List)
|
||||||
|
.map((json) => CotisationModel.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('Format de réponse invalide pour la liste des cotisations');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioException(e, 'Erreur lors de la récupération des cotisations');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère une cotisation par son ID
|
||||||
|
Future<CotisationModel> getCotisationById(String id) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/cotisations/$id');
|
||||||
|
return CotisationModel.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioException(e, 'Erreur lors de la récupération de la cotisation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère une cotisation par son numéro de référence
|
||||||
|
Future<CotisationModel> getCotisationByReference(String numeroReference) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/cotisations/reference/$numeroReference');
|
||||||
|
return CotisationModel.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioException(e, 'Erreur lors de la récupération de la cotisation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée une nouvelle cotisation
|
||||||
|
Future<CotisationModel> createCotisation(CotisationModel cotisation) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post(
|
||||||
|
'/api/cotisations',
|
||||||
|
data: cotisation.toJson(),
|
||||||
|
);
|
||||||
|
return CotisationModel.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioException(e, 'Erreur lors de la création de la cotisation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour une cotisation existante
|
||||||
|
Future<CotisationModel> updateCotisation(String id, CotisationModel cotisation) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.put(
|
||||||
|
'/api/cotisations/$id',
|
||||||
|
data: cotisation.toJson(),
|
||||||
|
);
|
||||||
|
return CotisationModel.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioException(e, 'Erreur lors de la mise à jour de la cotisation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime une cotisation
|
||||||
|
Future<void> deleteCotisation(String id) async {
|
||||||
|
try {
|
||||||
|
await _dio.delete('/api/cotisations/$id');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioException(e, 'Erreur lors de la suppression de la cotisation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les cotisations d'un membre
|
||||||
|
Future<List<CotisationModel>> getCotisationsByMembre(String membreId, {int page = 0, int size = 20}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/cotisations/membre/$membreId', queryParameters: {
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data is List) {
|
||||||
|
return (response.data as List)
|
||||||
|
.map((json) => CotisationModel.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('Format de réponse invalide pour les cotisations du membre');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioException(e, 'Erreur lors de la récupération des cotisations du membre');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les cotisations par statut
|
||||||
|
Future<List<CotisationModel>> getCotisationsByStatut(String statut, {int page = 0, int size = 20}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/cotisations/statut/$statut', queryParameters: {
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data is List) {
|
||||||
|
return (response.data as List)
|
||||||
|
.map((json) => CotisationModel.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('Format de réponse invalide pour les cotisations par statut');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioException(e, 'Erreur lors de la récupération des cotisations par statut');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les cotisations en retard
|
||||||
|
Future<List<CotisationModel>> getCotisationsEnRetard({int page = 0, int size = 20}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/cotisations/en-retard', queryParameters: {
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data is List) {
|
||||||
|
return (response.data as List)
|
||||||
|
.map((json) => CotisationModel.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('Format de réponse invalide pour les cotisations en retard');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioException(e, 'Erreur lors de la récupération des cotisations en retard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche avancée de cotisations
|
||||||
|
Future<List<CotisationModel>> rechercherCotisations({
|
||||||
|
String? membreId,
|
||||||
|
String? statut,
|
||||||
|
String? typeCotisation,
|
||||||
|
int? annee,
|
||||||
|
int? mois,
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final queryParams = <String, dynamic>{
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (membreId != null) queryParams['membreId'] = membreId;
|
||||||
|
if (statut != null) queryParams['statut'] = statut;
|
||||||
|
if (typeCotisation != null) queryParams['typeCotisation'] = typeCotisation;
|
||||||
|
if (annee != null) queryParams['annee'] = annee;
|
||||||
|
if (mois != null) queryParams['mois'] = mois;
|
||||||
|
|
||||||
|
final response = await _dio.get('/api/cotisations/recherche', queryParameters: queryParams);
|
||||||
|
|
||||||
|
if (response.data is List) {
|
||||||
|
return (response.data as List)
|
||||||
|
.map((json) => CotisationModel.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('Format de réponse invalide pour la recherche de cotisations');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioException(e, 'Erreur lors de la recherche de cotisations');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les statistiques des cotisations
|
||||||
|
Future<Map<String, dynamic>> getCotisationsStats() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/cotisations/stats');
|
||||||
|
return response.data as Map<String, dynamic>;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw _handleDioException(e, 'Erreur lors de la récupération des statistiques des cotisations');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// GESTION DES ERREURS
|
// GESTION DES ERREURS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../../core/auth/bloc/auth_bloc.dart';
|
import '../../../../core/auth/bloc/temp_auth_bloc.dart';
|
||||||
import '../../../../core/auth/bloc/auth_event.dart';
|
import '../../../../core/auth/bloc/auth_event.dart';
|
||||||
import '../../../../core/auth/models/auth_state.dart';
|
import '../../../../core/auth/models/auth_state.dart';
|
||||||
import '../../../../core/auth/models/login_request.dart';
|
import '../../../../core/auth/models/login_request.dart';
|
||||||
@@ -100,7 +100,7 @@ class _LoginPageState extends State<LoginPage>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.backgroundLight,
|
backgroundColor: AppTheme.backgroundLight,
|
||||||
body: BlocConsumer<AuthBloc, AuthState>(
|
body: BlocConsumer<TempAuthBloc, AuthState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = state.status == AuthStatus.checking;
|
_isLoading = state.status == AuthStatus.checking;
|
||||||
@@ -222,7 +222,7 @@ class _LoginPageState extends State<LoginPage>
|
|||||||
rememberMe: _rememberMe,
|
rememberMe: _rememberMe,
|
||||||
);
|
);
|
||||||
|
|
||||||
context.read<AuthBloc>().add(AuthLoginRequested(loginRequest));
|
context.read<TempAuthBloc>().add(AuthLoginRequested(loginRequest));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showErrorSnackBar(String message) {
|
void _showErrorSnackBar(String message) {
|
||||||
|
|||||||
@@ -59,14 +59,18 @@ class _LoginFormState extends State<LoginForm>
|
|||||||
);
|
);
|
||||||
|
|
||||||
_fieldAnimations = List.generate(4, (index) {
|
_fieldAnimations = List.generate(4, (index) {
|
||||||
|
// Calcul sécurisé pour éviter end > 1.0
|
||||||
|
final start = index * 0.15; // Réduit l'espacement
|
||||||
|
final end = (start + 0.4).clamp(0.0, 1.0); // Assure end <= 1.0
|
||||||
|
|
||||||
return Tween<Offset>(
|
return Tween<Offset>(
|
||||||
begin: const Offset(0, 1),
|
begin: const Offset(0, 1),
|
||||||
end: Offset.zero,
|
end: Offset.zero,
|
||||||
).animate(CurvedAnimation(
|
).animate(CurvedAnimation(
|
||||||
parent: _fieldAnimationController,
|
parent: _fieldAnimationController,
|
||||||
curve: Interval(
|
curve: Interval(
|
||||||
index * 0.2,
|
start,
|
||||||
(index * 0.2) + 0.6,
|
end,
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
@@ -330,13 +334,13 @@ class _LoginFormState extends State<LoginForm>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: widget.rememberMe
|
color: widget.rememberMe
|
||||||
? AppTheme.primaryColor
|
? AppTheme.primaryColor
|
||||||
: AppTheme.textSecondary,
|
: AppTheme.textSecondary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
color: widget.rememberMe
|
color: widget.rememberMe
|
||||||
? AppTheme.primaryColor
|
? AppTheme.primaryColor
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
child: widget.rememberMe
|
child: widget.rememberMe
|
||||||
@@ -348,12 +352,15 @@ class _LoginFormState extends State<LoginForm>
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Flexible(
|
||||||
'Se souvenir de moi',
|
child: Text(
|
||||||
style: TextStyle(
|
'Se souvenir de moi',
|
||||||
fontSize: 14,
|
style: TextStyle(
|
||||||
color: AppTheme.textSecondary,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
color: AppTheme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:injectable/injectable.dart';
|
||||||
|
import '../../../../core/models/cotisation_model.dart';
|
||||||
|
import '../../../../core/services/api_service.dart';
|
||||||
|
import '../../../cotisations/domain/repositories/cotisation_repository.dart';
|
||||||
|
|
||||||
|
/// Implémentation du repository des cotisations
|
||||||
|
/// Utilise ApiService pour communiquer avec le backend
|
||||||
|
@LazySingleton(as: CotisationRepository)
|
||||||
|
class CotisationRepositoryImpl implements CotisationRepository {
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
CotisationRepositoryImpl(this._apiService);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CotisationModel>> getCotisations({int page = 0, int size = 20}) async {
|
||||||
|
return await _apiService.getCotisations(page: page, size: size);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CotisationModel> getCotisationById(String id) async {
|
||||||
|
return await _apiService.getCotisationById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CotisationModel> getCotisationByReference(String numeroReference) async {
|
||||||
|
return await _apiService.getCotisationByReference(numeroReference);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CotisationModel> createCotisation(CotisationModel cotisation) async {
|
||||||
|
return await _apiService.createCotisation(cotisation);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CotisationModel> updateCotisation(String id, CotisationModel cotisation) async {
|
||||||
|
return await _apiService.updateCotisation(id, cotisation);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteCotisation(String id) async {
|
||||||
|
return await _apiService.deleteCotisation(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CotisationModel>> getCotisationsByMembre(String membreId, {int page = 0, int size = 20}) async {
|
||||||
|
return await _apiService.getCotisationsByMembre(membreId, page: page, size: size);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CotisationModel>> getCotisationsByStatut(String statut, {int page = 0, int size = 20}) async {
|
||||||
|
return await _apiService.getCotisationsByStatut(statut, page: page, size: size);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CotisationModel>> getCotisationsEnRetard({int page = 0, int size = 20}) async {
|
||||||
|
return await _apiService.getCotisationsEnRetard(page: page, size: size);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CotisationModel>> rechercherCotisations({
|
||||||
|
String? membreId,
|
||||||
|
String? statut,
|
||||||
|
String? typeCotisation,
|
||||||
|
int? annee,
|
||||||
|
int? mois,
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
return await _apiService.rechercherCotisations(
|
||||||
|
membreId: membreId,
|
||||||
|
statut: statut,
|
||||||
|
typeCotisation: typeCotisation,
|
||||||
|
annee: annee,
|
||||||
|
mois: mois,
|
||||||
|
page: page,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> getCotisationsStats() async {
|
||||||
|
return await _apiService.getCotisationsStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import '../../../../core/models/cotisation_model.dart';
|
||||||
|
|
||||||
|
/// Interface du repository des cotisations
|
||||||
|
/// Définit les contrats pour l'accès aux données des cotisations
|
||||||
|
abstract class CotisationRepository {
|
||||||
|
/// Récupère la liste de toutes les cotisations avec pagination
|
||||||
|
Future<List<CotisationModel>> getCotisations({int page = 0, int size = 20});
|
||||||
|
|
||||||
|
/// Récupère une cotisation par son ID
|
||||||
|
Future<CotisationModel> getCotisationById(String id);
|
||||||
|
|
||||||
|
/// Récupère une cotisation par son numéro de référence
|
||||||
|
Future<CotisationModel> getCotisationByReference(String numeroReference);
|
||||||
|
|
||||||
|
/// Crée une nouvelle cotisation
|
||||||
|
Future<CotisationModel> createCotisation(CotisationModel cotisation);
|
||||||
|
|
||||||
|
/// Met à jour une cotisation existante
|
||||||
|
Future<CotisationModel> updateCotisation(String id, CotisationModel cotisation);
|
||||||
|
|
||||||
|
/// Supprime une cotisation
|
||||||
|
Future<void> deleteCotisation(String id);
|
||||||
|
|
||||||
|
/// Récupère les cotisations d'un membre
|
||||||
|
Future<List<CotisationModel>> getCotisationsByMembre(String membreId, {int page = 0, int size = 20});
|
||||||
|
|
||||||
|
/// Récupère les cotisations par statut
|
||||||
|
Future<List<CotisationModel>> getCotisationsByStatut(String statut, {int page = 0, int size = 20});
|
||||||
|
|
||||||
|
/// Récupère les cotisations en retard
|
||||||
|
Future<List<CotisationModel>> getCotisationsEnRetard({int page = 0, int size = 20});
|
||||||
|
|
||||||
|
/// Recherche avancée de cotisations
|
||||||
|
Future<List<CotisationModel>> rechercherCotisations({
|
||||||
|
String? membreId,
|
||||||
|
String? statut,
|
||||||
|
String? typeCotisation,
|
||||||
|
int? annee,
|
||||||
|
int? mois,
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Récupère les statistiques des cotisations
|
||||||
|
Future<Map<String, dynamic>> getCotisationsStats();
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:injectable/injectable.dart';
|
||||||
|
import '../../../../core/models/cotisation_model.dart';
|
||||||
|
import '../../domain/repositories/cotisation_repository.dart';
|
||||||
|
import 'cotisations_event.dart';
|
||||||
|
import 'cotisations_state.dart';
|
||||||
|
|
||||||
|
/// BLoC pour la gestion des cotisations
|
||||||
|
/// Gère l'état et les événements liés aux cotisations
|
||||||
|
@injectable
|
||||||
|
class CotisationsBloc extends Bloc<CotisationsEvent, CotisationsState> {
|
||||||
|
final CotisationRepository _cotisationRepository;
|
||||||
|
|
||||||
|
CotisationsBloc(this._cotisationRepository) : super(const CotisationsInitial()) {
|
||||||
|
// Enregistrement des handlers d'événements
|
||||||
|
on<LoadCotisations>(_onLoadCotisations);
|
||||||
|
on<LoadCotisationById>(_onLoadCotisationById);
|
||||||
|
on<LoadCotisationByReference>(_onLoadCotisationByReference);
|
||||||
|
on<CreateCotisation>(_onCreateCotisation);
|
||||||
|
on<UpdateCotisation>(_onUpdateCotisation);
|
||||||
|
on<DeleteCotisation>(_onDeleteCotisation);
|
||||||
|
on<LoadCotisationsByMembre>(_onLoadCotisationsByMembre);
|
||||||
|
on<LoadCotisationsByStatut>(_onLoadCotisationsByStatut);
|
||||||
|
on<LoadCotisationsEnRetard>(_onLoadCotisationsEnRetard);
|
||||||
|
on<SearchCotisations>(_onSearchCotisations);
|
||||||
|
on<LoadCotisationsStats>(_onLoadCotisationsStats);
|
||||||
|
on<RefreshCotisations>(_onRefreshCotisations);
|
||||||
|
on<ResetCotisationsState>(_onResetCotisationsState);
|
||||||
|
on<FilterCotisations>(_onFilterCotisations);
|
||||||
|
on<SortCotisations>(_onSortCotisations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour charger la liste des cotisations
|
||||||
|
Future<void> _onLoadCotisations(
|
||||||
|
LoadCotisations event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
if (event.refresh || state is CotisationsInitial) {
|
||||||
|
emit(CotisationsLoading(isRefreshing: event.refresh));
|
||||||
|
}
|
||||||
|
|
||||||
|
final cotisations = await _cotisationRepository.getCotisations(
|
||||||
|
page: event.page,
|
||||||
|
size: event.size,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<CotisationModel> allCotisations = [];
|
||||||
|
|
||||||
|
// Si c'est un refresh ou la première page, remplacer la liste
|
||||||
|
if (event.refresh || event.page == 0) {
|
||||||
|
allCotisations = cotisations;
|
||||||
|
} else {
|
||||||
|
// Sinon, ajouter à la liste existante (pagination)
|
||||||
|
if (state is CotisationsLoaded) {
|
||||||
|
final currentState = state as CotisationsLoaded;
|
||||||
|
allCotisations = [...currentState.cotisations, ...cotisations];
|
||||||
|
} else {
|
||||||
|
allCotisations = cotisations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(CotisationsLoaded(
|
||||||
|
cotisations: allCotisations,
|
||||||
|
filteredCotisations: allCotisations,
|
||||||
|
hasReachedMax: cotisations.length < event.size,
|
||||||
|
currentPage: event.page,
|
||||||
|
));
|
||||||
|
} catch (error) {
|
||||||
|
emit(CotisationsError(
|
||||||
|
'Erreur lors du chargement des cotisations: ${error.toString()}',
|
||||||
|
originalError: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour charger une cotisation par ID
|
||||||
|
Future<void> _onLoadCotisationById(
|
||||||
|
LoadCotisationById event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
emit(const CotisationsLoading());
|
||||||
|
|
||||||
|
final cotisation = await _cotisationRepository.getCotisationById(event.id);
|
||||||
|
|
||||||
|
emit(CotisationDetailLoaded(cotisation));
|
||||||
|
} catch (error) {
|
||||||
|
emit(CotisationsError(
|
||||||
|
'Erreur lors du chargement de la cotisation: ${error.toString()}',
|
||||||
|
originalError: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour charger une cotisation par référence
|
||||||
|
Future<void> _onLoadCotisationByReference(
|
||||||
|
LoadCotisationByReference event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
emit(const CotisationsLoading());
|
||||||
|
|
||||||
|
final cotisation = await _cotisationRepository.getCotisationByReference(event.numeroReference);
|
||||||
|
|
||||||
|
emit(CotisationDetailLoaded(cotisation));
|
||||||
|
} catch (error) {
|
||||||
|
emit(CotisationsError(
|
||||||
|
'Erreur lors du chargement de la cotisation: ${error.toString()}',
|
||||||
|
originalError: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour créer une nouvelle cotisation
|
||||||
|
Future<void> _onCreateCotisation(
|
||||||
|
CreateCotisation event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
emit(const CotisationOperationLoading('create'));
|
||||||
|
|
||||||
|
final nouvelleCotisation = await _cotisationRepository.createCotisation(event.cotisation);
|
||||||
|
|
||||||
|
emit(CotisationCreated(nouvelleCotisation));
|
||||||
|
|
||||||
|
// Recharger la liste des cotisations
|
||||||
|
add(const LoadCotisations(refresh: true));
|
||||||
|
} catch (error) {
|
||||||
|
emit(CotisationsError(
|
||||||
|
'Erreur lors de la création de la cotisation: ${error.toString()}',
|
||||||
|
originalError: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour mettre à jour une cotisation
|
||||||
|
Future<void> _onUpdateCotisation(
|
||||||
|
UpdateCotisation event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
emit(CotisationOperationLoading('update', cotisationId: event.id));
|
||||||
|
|
||||||
|
final cotisationMiseAJour = await _cotisationRepository.updateCotisation(
|
||||||
|
event.id,
|
||||||
|
event.cotisation,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(CotisationUpdated(cotisationMiseAJour));
|
||||||
|
|
||||||
|
// Mettre à jour la liste si elle est chargée
|
||||||
|
if (state is CotisationsLoaded) {
|
||||||
|
final currentState = state as CotisationsLoaded;
|
||||||
|
final updatedList = currentState.cotisations.map((c) {
|
||||||
|
return c.id == event.id ? cotisationMiseAJour : c;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
emit(currentState.copyWith(
|
||||||
|
cotisations: updatedList,
|
||||||
|
filteredCotisations: updatedList,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
emit(CotisationsError(
|
||||||
|
'Erreur lors de la mise à jour de la cotisation: ${error.toString()}',
|
||||||
|
originalError: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour supprimer une cotisation
|
||||||
|
Future<void> _onDeleteCotisation(
|
||||||
|
DeleteCotisation event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
emit(CotisationOperationLoading('delete', cotisationId: event.id));
|
||||||
|
|
||||||
|
await _cotisationRepository.deleteCotisation(event.id);
|
||||||
|
|
||||||
|
emit(CotisationDeleted(event.id));
|
||||||
|
|
||||||
|
// Retirer de la liste si elle est chargée
|
||||||
|
if (state is CotisationsLoaded) {
|
||||||
|
final currentState = state as CotisationsLoaded;
|
||||||
|
final updatedList = currentState.cotisations
|
||||||
|
.where((c) => c.id != event.id)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
emit(currentState.copyWith(
|
||||||
|
cotisations: updatedList,
|
||||||
|
filteredCotisations: updatedList,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
emit(CotisationsError(
|
||||||
|
'Erreur lors de la suppression de la cotisation: ${error.toString()}',
|
||||||
|
originalError: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour charger les cotisations d'un membre
|
||||||
|
Future<void> _onLoadCotisationsByMembre(
|
||||||
|
LoadCotisationsByMembre event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
if (event.refresh || event.page == 0) {
|
||||||
|
emit(CotisationsLoading(isRefreshing: event.refresh));
|
||||||
|
}
|
||||||
|
|
||||||
|
final cotisations = await _cotisationRepository.getCotisationsByMembre(
|
||||||
|
event.membreId,
|
||||||
|
page: event.page,
|
||||||
|
size: event.size,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<CotisationModel> allCotisations = [];
|
||||||
|
|
||||||
|
if (event.refresh || event.page == 0) {
|
||||||
|
allCotisations = cotisations;
|
||||||
|
} else {
|
||||||
|
if (state is CotisationsByMembreLoaded) {
|
||||||
|
final currentState = state as CotisationsByMembreLoaded;
|
||||||
|
allCotisations = [...currentState.cotisations, ...cotisations];
|
||||||
|
} else {
|
||||||
|
allCotisations = cotisations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(CotisationsByMembreLoaded(
|
||||||
|
membreId: event.membreId,
|
||||||
|
cotisations: allCotisations,
|
||||||
|
hasReachedMax: cotisations.length < event.size,
|
||||||
|
currentPage: event.page,
|
||||||
|
));
|
||||||
|
} catch (error) {
|
||||||
|
emit(CotisationsError(
|
||||||
|
'Erreur lors du chargement des cotisations du membre: ${error.toString()}',
|
||||||
|
originalError: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour charger les cotisations par statut
|
||||||
|
Future<void> _onLoadCotisationsByStatut(
|
||||||
|
LoadCotisationsByStatut event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
if (event.refresh || event.page == 0) {
|
||||||
|
emit(CotisationsLoading(isRefreshing: event.refresh));
|
||||||
|
}
|
||||||
|
|
||||||
|
final cotisations = await _cotisationRepository.getCotisationsByStatut(
|
||||||
|
event.statut,
|
||||||
|
page: event.page,
|
||||||
|
size: event.size,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<CotisationModel> allCotisations = [];
|
||||||
|
|
||||||
|
if (event.refresh || event.page == 0) {
|
||||||
|
allCotisations = cotisations;
|
||||||
|
} else {
|
||||||
|
if (state is CotisationsLoaded) {
|
||||||
|
final currentState = state as CotisationsLoaded;
|
||||||
|
allCotisations = [...currentState.cotisations, ...cotisations];
|
||||||
|
} else {
|
||||||
|
allCotisations = cotisations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(CotisationsLoaded(
|
||||||
|
cotisations: allCotisations,
|
||||||
|
filteredCotisations: allCotisations,
|
||||||
|
hasReachedMax: cotisations.length < event.size,
|
||||||
|
currentPage: event.page,
|
||||||
|
currentFilter: event.statut,
|
||||||
|
));
|
||||||
|
} catch (error) {
|
||||||
|
emit(CotisationsError(
|
||||||
|
'Erreur lors du chargement des cotisations par statut: ${error.toString()}',
|
||||||
|
originalError: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour charger les cotisations en retard
|
||||||
|
Future<void> _onLoadCotisationsEnRetard(
|
||||||
|
LoadCotisationsEnRetard event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
if (event.refresh || event.page == 0) {
|
||||||
|
emit(CotisationsLoading(isRefreshing: event.refresh));
|
||||||
|
}
|
||||||
|
|
||||||
|
final cotisations = await _cotisationRepository.getCotisationsEnRetard(
|
||||||
|
page: event.page,
|
||||||
|
size: event.size,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<CotisationModel> allCotisations = [];
|
||||||
|
|
||||||
|
if (event.refresh || event.page == 0) {
|
||||||
|
allCotisations = cotisations;
|
||||||
|
} else {
|
||||||
|
if (state is CotisationsEnRetardLoaded) {
|
||||||
|
final currentState = state as CotisationsEnRetardLoaded;
|
||||||
|
allCotisations = [...currentState.cotisations, ...cotisations];
|
||||||
|
} else {
|
||||||
|
allCotisations = cotisations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(CotisationsEnRetardLoaded(
|
||||||
|
cotisations: allCotisations,
|
||||||
|
hasReachedMax: cotisations.length < event.size,
|
||||||
|
currentPage: event.page,
|
||||||
|
));
|
||||||
|
} catch (error) {
|
||||||
|
emit(CotisationsError(
|
||||||
|
'Erreur lors du chargement des cotisations en retard: ${error.toString()}',
|
||||||
|
originalError: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour la recherche de cotisations
|
||||||
|
Future<void> _onSearchCotisations(
|
||||||
|
SearchCotisations event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
if (event.refresh || event.page == 0) {
|
||||||
|
emit(CotisationsLoading(isRefreshing: event.refresh));
|
||||||
|
}
|
||||||
|
|
||||||
|
final cotisations = await _cotisationRepository.rechercherCotisations(
|
||||||
|
membreId: event.membreId,
|
||||||
|
statut: event.statut,
|
||||||
|
typeCotisation: event.typeCotisation,
|
||||||
|
annee: event.annee,
|
||||||
|
mois: event.mois,
|
||||||
|
page: event.page,
|
||||||
|
size: event.size,
|
||||||
|
);
|
||||||
|
|
||||||
|
final searchCriteria = <String, dynamic>{
|
||||||
|
if (event.membreId != null) 'membreId': event.membreId,
|
||||||
|
if (event.statut != null) 'statut': event.statut,
|
||||||
|
if (event.typeCotisation != null) 'typeCotisation': event.typeCotisation,
|
||||||
|
if (event.annee != null) 'annee': event.annee,
|
||||||
|
if (event.mois != null) 'mois': event.mois,
|
||||||
|
};
|
||||||
|
|
||||||
|
List<CotisationModel> allCotisations = [];
|
||||||
|
|
||||||
|
if (event.refresh || event.page == 0) {
|
||||||
|
allCotisations = cotisations;
|
||||||
|
} else {
|
||||||
|
if (state is CotisationsSearchResults) {
|
||||||
|
final currentState = state as CotisationsSearchResults;
|
||||||
|
allCotisations = [...currentState.cotisations, ...cotisations];
|
||||||
|
} else {
|
||||||
|
allCotisations = cotisations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(CotisationsSearchResults(
|
||||||
|
cotisations: allCotisations,
|
||||||
|
searchCriteria: searchCriteria,
|
||||||
|
hasReachedMax: cotisations.length < event.size,
|
||||||
|
currentPage: event.page,
|
||||||
|
));
|
||||||
|
} catch (error) {
|
||||||
|
emit(CotisationsError(
|
||||||
|
'Erreur lors de la recherche de cotisations: ${error.toString()}',
|
||||||
|
originalError: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour charger les statistiques
|
||||||
|
Future<void> _onLoadCotisationsStats(
|
||||||
|
LoadCotisationsStats event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
emit(const CotisationsLoading());
|
||||||
|
|
||||||
|
final statistics = await _cotisationRepository.getCotisationsStats();
|
||||||
|
|
||||||
|
emit(CotisationsStatsLoaded(statistics));
|
||||||
|
} catch (error) {
|
||||||
|
emit(CotisationsError(
|
||||||
|
'Erreur lors du chargement des statistiques: ${error.toString()}',
|
||||||
|
originalError: error,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour rafraîchir les données
|
||||||
|
Future<void> _onRefreshCotisations(
|
||||||
|
RefreshCotisations event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
add(const LoadCotisations(refresh: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour réinitialiser l'état
|
||||||
|
Future<void> _onResetCotisationsState(
|
||||||
|
ResetCotisationsState event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
emit(const CotisationsInitial());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour filtrer les cotisations localement
|
||||||
|
Future<void> _onFilterCotisations(
|
||||||
|
FilterCotisations event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
if (state is CotisationsLoaded) {
|
||||||
|
final currentState = state as CotisationsLoaded;
|
||||||
|
|
||||||
|
List<CotisationModel> filteredList = currentState.cotisations;
|
||||||
|
|
||||||
|
// Filtrage par recherche textuelle
|
||||||
|
if (event.searchQuery != null && event.searchQuery!.isNotEmpty) {
|
||||||
|
final query = event.searchQuery!.toLowerCase();
|
||||||
|
filteredList = filteredList.where((cotisation) {
|
||||||
|
return cotisation.numeroReference.toLowerCase().contains(query) ||
|
||||||
|
(cotisation.nomMembre?.toLowerCase().contains(query) ?? false) ||
|
||||||
|
cotisation.typeCotisation.toLowerCase().contains(query) ||
|
||||||
|
(cotisation.description?.toLowerCase().contains(query) ?? false);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrage par statut
|
||||||
|
if (event.statutFilter != null && event.statutFilter!.isNotEmpty) {
|
||||||
|
filteredList = filteredList.where((cotisation) {
|
||||||
|
return cotisation.statut == event.statutFilter;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrage par type
|
||||||
|
if (event.typeFilter != null && event.typeFilter!.isNotEmpty) {
|
||||||
|
filteredList = filteredList.where((cotisation) {
|
||||||
|
return cotisation.typeCotisation == event.typeFilter;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(currentState.copyWith(
|
||||||
|
filteredCotisations: filteredList,
|
||||||
|
searchQuery: event.searchQuery,
|
||||||
|
currentFilter: event.statutFilter ?? event.typeFilter,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler pour trier les cotisations
|
||||||
|
Future<void> _onSortCotisations(
|
||||||
|
SortCotisations event,
|
||||||
|
Emitter<CotisationsState> emit,
|
||||||
|
) async {
|
||||||
|
if (state is CotisationsLoaded) {
|
||||||
|
final currentState = state as CotisationsLoaded;
|
||||||
|
|
||||||
|
List<CotisationModel> sortedList = [...currentState.filteredCotisations];
|
||||||
|
|
||||||
|
switch (event.sortBy) {
|
||||||
|
case 'dateEcheance':
|
||||||
|
sortedList.sort((a, b) => event.ascending
|
||||||
|
? a.dateEcheance.compareTo(b.dateEcheance)
|
||||||
|
: b.dateEcheance.compareTo(a.dateEcheance));
|
||||||
|
break;
|
||||||
|
case 'montantDu':
|
||||||
|
sortedList.sort((a, b) => event.ascending
|
||||||
|
? a.montantDu.compareTo(b.montantDu)
|
||||||
|
: b.montantDu.compareTo(a.montantDu));
|
||||||
|
break;
|
||||||
|
case 'statut':
|
||||||
|
sortedList.sort((a, b) => event.ascending
|
||||||
|
? a.statut.compareTo(b.statut)
|
||||||
|
: b.statut.compareTo(a.statut));
|
||||||
|
break;
|
||||||
|
case 'nomMembre':
|
||||||
|
sortedList.sort((a, b) => event.ascending
|
||||||
|
? (a.nomMembre ?? '').compareTo(b.nomMembre ?? '')
|
||||||
|
: (b.nomMembre ?? '').compareTo(a.nomMembre ?? ''));
|
||||||
|
break;
|
||||||
|
case 'typeCotisation':
|
||||||
|
sortedList.sort((a, b) => event.ascending
|
||||||
|
? a.typeCotisation.compareTo(b.typeCotisation)
|
||||||
|
: b.typeCotisation.compareTo(a.typeCotisation));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Tri par défaut par date d'échéance
|
||||||
|
sortedList.sort((a, b) => b.dateEcheance.compareTo(a.dateEcheance));
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(currentState.copyWith(filteredCotisations: sortedList));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import '../../../../core/models/cotisation_model.dart';
|
||||||
|
|
||||||
|
/// Événements du BLoC des cotisations
|
||||||
|
abstract class CotisationsEvent extends Equatable {
|
||||||
|
const CotisationsEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour charger la liste des cotisations
|
||||||
|
class LoadCotisations extends CotisationsEvent {
|
||||||
|
final int page;
|
||||||
|
final int size;
|
||||||
|
final bool refresh;
|
||||||
|
|
||||||
|
const LoadCotisations({
|
||||||
|
this.page = 0,
|
||||||
|
this.size = 20,
|
||||||
|
this.refresh = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [page, size, refresh];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour charger une cotisation par ID
|
||||||
|
class LoadCotisationById extends CotisationsEvent {
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
const LoadCotisationById(this.id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour charger une cotisation par référence
|
||||||
|
class LoadCotisationByReference extends CotisationsEvent {
|
||||||
|
final String numeroReference;
|
||||||
|
|
||||||
|
const LoadCotisationByReference(this.numeroReference);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [numeroReference];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour créer une nouvelle cotisation
|
||||||
|
class CreateCotisation extends CotisationsEvent {
|
||||||
|
final CotisationModel cotisation;
|
||||||
|
|
||||||
|
const CreateCotisation(this.cotisation);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [cotisation];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour mettre à jour une cotisation
|
||||||
|
class UpdateCotisation extends CotisationsEvent {
|
||||||
|
final String id;
|
||||||
|
final CotisationModel cotisation;
|
||||||
|
|
||||||
|
const UpdateCotisation(this.id, this.cotisation);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, cotisation];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour supprimer une cotisation
|
||||||
|
class DeleteCotisation extends CotisationsEvent {
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
const DeleteCotisation(this.id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour charger les cotisations d'un membre
|
||||||
|
class LoadCotisationsByMembre extends CotisationsEvent {
|
||||||
|
final String membreId;
|
||||||
|
final int page;
|
||||||
|
final int size;
|
||||||
|
final bool refresh;
|
||||||
|
|
||||||
|
const LoadCotisationsByMembre(
|
||||||
|
this.membreId, {
|
||||||
|
this.page = 0,
|
||||||
|
this.size = 20,
|
||||||
|
this.refresh = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [membreId, page, size, refresh];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour charger les cotisations par statut
|
||||||
|
class LoadCotisationsByStatut extends CotisationsEvent {
|
||||||
|
final String statut;
|
||||||
|
final int page;
|
||||||
|
final int size;
|
||||||
|
final bool refresh;
|
||||||
|
|
||||||
|
const LoadCotisationsByStatut(
|
||||||
|
this.statut, {
|
||||||
|
this.page = 0,
|
||||||
|
this.size = 20,
|
||||||
|
this.refresh = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [statut, page, size, refresh];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour charger les cotisations en retard
|
||||||
|
class LoadCotisationsEnRetard extends CotisationsEvent {
|
||||||
|
final int page;
|
||||||
|
final int size;
|
||||||
|
final bool refresh;
|
||||||
|
|
||||||
|
const LoadCotisationsEnRetard({
|
||||||
|
this.page = 0,
|
||||||
|
this.size = 20,
|
||||||
|
this.refresh = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [page, size, refresh];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour rechercher des cotisations
|
||||||
|
class SearchCotisations extends CotisationsEvent {
|
||||||
|
final String? membreId;
|
||||||
|
final String? statut;
|
||||||
|
final String? typeCotisation;
|
||||||
|
final int? annee;
|
||||||
|
final int? mois;
|
||||||
|
final int page;
|
||||||
|
final int size;
|
||||||
|
final bool refresh;
|
||||||
|
|
||||||
|
const SearchCotisations({
|
||||||
|
this.membreId,
|
||||||
|
this.statut,
|
||||||
|
this.typeCotisation,
|
||||||
|
this.annee,
|
||||||
|
this.mois,
|
||||||
|
this.page = 0,
|
||||||
|
this.size = 20,
|
||||||
|
this.refresh = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
membreId,
|
||||||
|
statut,
|
||||||
|
typeCotisation,
|
||||||
|
annee,
|
||||||
|
mois,
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
refresh,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour charger les statistiques
|
||||||
|
class LoadCotisationsStats extends CotisationsEvent {
|
||||||
|
const LoadCotisationsStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour rafraîchir les données
|
||||||
|
class RefreshCotisations extends CotisationsEvent {
|
||||||
|
const RefreshCotisations();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour réinitialiser l'état
|
||||||
|
class ResetCotisationsState extends CotisationsEvent {
|
||||||
|
const ResetCotisationsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour filtrer les cotisations localement
|
||||||
|
class FilterCotisations extends CotisationsEvent {
|
||||||
|
final String? searchQuery;
|
||||||
|
final String? statutFilter;
|
||||||
|
final String? typeFilter;
|
||||||
|
|
||||||
|
const FilterCotisations({
|
||||||
|
this.searchQuery,
|
||||||
|
this.statutFilter,
|
||||||
|
this.typeFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [searchQuery, statutFilter, typeFilter];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Événement pour trier les cotisations
|
||||||
|
class SortCotisations extends CotisationsEvent {
|
||||||
|
final String sortBy; // 'dateEcheance', 'montantDu', 'statut', etc.
|
||||||
|
final bool ascending;
|
||||||
|
|
||||||
|
const SortCotisations(this.sortBy, {this.ascending = true});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [sortBy, ascending];
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import '../../../../core/models/cotisation_model.dart';
|
||||||
|
|
||||||
|
/// États du BLoC des cotisations
|
||||||
|
abstract class CotisationsState extends Equatable {
|
||||||
|
const CotisationsState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État initial
|
||||||
|
class CotisationsInitial extends CotisationsState {
|
||||||
|
const CotisationsInitial();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État de chargement
|
||||||
|
class CotisationsLoading extends CotisationsState {
|
||||||
|
final bool isRefreshing;
|
||||||
|
|
||||||
|
const CotisationsLoading({this.isRefreshing = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [isRefreshing];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État de succès avec liste des cotisations
|
||||||
|
class CotisationsLoaded extends CotisationsState {
|
||||||
|
final List<CotisationModel> cotisations;
|
||||||
|
final List<CotisationModel> filteredCotisations;
|
||||||
|
final Map<String, dynamic>? statistics;
|
||||||
|
final bool hasReachedMax;
|
||||||
|
final int currentPage;
|
||||||
|
final String? currentFilter;
|
||||||
|
final String? searchQuery;
|
||||||
|
|
||||||
|
const CotisationsLoaded({
|
||||||
|
required this.cotisations,
|
||||||
|
required this.filteredCotisations,
|
||||||
|
this.statistics,
|
||||||
|
this.hasReachedMax = false,
|
||||||
|
this.currentPage = 0,
|
||||||
|
this.currentFilter,
|
||||||
|
this.searchQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Copie avec modifications
|
||||||
|
CotisationsLoaded copyWith({
|
||||||
|
List<CotisationModel>? cotisations,
|
||||||
|
List<CotisationModel>? filteredCotisations,
|
||||||
|
Map<String, dynamic>? statistics,
|
||||||
|
bool? hasReachedMax,
|
||||||
|
int? currentPage,
|
||||||
|
String? currentFilter,
|
||||||
|
String? searchQuery,
|
||||||
|
}) {
|
||||||
|
return CotisationsLoaded(
|
||||||
|
cotisations: cotisations ?? this.cotisations,
|
||||||
|
filteredCotisations: filteredCotisations ?? this.filteredCotisations,
|
||||||
|
statistics: statistics ?? this.statistics,
|
||||||
|
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
||||||
|
currentPage: currentPage ?? this.currentPage,
|
||||||
|
currentFilter: currentFilter ?? this.currentFilter,
|
||||||
|
searchQuery: searchQuery ?? this.searchQuery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
cotisations,
|
||||||
|
filteredCotisations,
|
||||||
|
statistics,
|
||||||
|
hasReachedMax,
|
||||||
|
currentPage,
|
||||||
|
currentFilter,
|
||||||
|
searchQuery,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État de succès pour une cotisation unique
|
||||||
|
class CotisationDetailLoaded extends CotisationsState {
|
||||||
|
final CotisationModel cotisation;
|
||||||
|
|
||||||
|
const CotisationDetailLoaded(this.cotisation);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [cotisation];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État de succès pour la création d'une cotisation
|
||||||
|
class CotisationCreated extends CotisationsState {
|
||||||
|
final CotisationModel cotisation;
|
||||||
|
|
||||||
|
const CotisationCreated(this.cotisation);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [cotisation];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État de succès pour la mise à jour d'une cotisation
|
||||||
|
class CotisationUpdated extends CotisationsState {
|
||||||
|
final CotisationModel cotisation;
|
||||||
|
|
||||||
|
const CotisationUpdated(this.cotisation);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [cotisation];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État de succès pour la suppression d'une cotisation
|
||||||
|
class CotisationDeleted extends CotisationsState {
|
||||||
|
final String cotisationId;
|
||||||
|
|
||||||
|
const CotisationDeleted(this.cotisationId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [cotisationId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État d'erreur
|
||||||
|
class CotisationsError extends CotisationsState {
|
||||||
|
final String message;
|
||||||
|
final String? errorCode;
|
||||||
|
final dynamic originalError;
|
||||||
|
|
||||||
|
const CotisationsError(
|
||||||
|
this.message, {
|
||||||
|
this.errorCode,
|
||||||
|
this.originalError,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [message, errorCode, originalError];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État de chargement pour une opération spécifique
|
||||||
|
class CotisationOperationLoading extends CotisationsState {
|
||||||
|
final String operation; // 'create', 'update', 'delete'
|
||||||
|
final String? cotisationId;
|
||||||
|
|
||||||
|
const CotisationOperationLoading(this.operation, {this.cotisationId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [operation, cotisationId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État de succès pour les statistiques
|
||||||
|
class CotisationsStatsLoaded extends CotisationsState {
|
||||||
|
final Map<String, dynamic> statistics;
|
||||||
|
|
||||||
|
const CotisationsStatsLoaded(this.statistics);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [statistics];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État pour les cotisations filtrées par membre
|
||||||
|
class CotisationsByMembreLoaded extends CotisationsState {
|
||||||
|
final String membreId;
|
||||||
|
final List<CotisationModel> cotisations;
|
||||||
|
final bool hasReachedMax;
|
||||||
|
final int currentPage;
|
||||||
|
|
||||||
|
const CotisationsByMembreLoaded({
|
||||||
|
required this.membreId,
|
||||||
|
required this.cotisations,
|
||||||
|
this.hasReachedMax = false,
|
||||||
|
this.currentPage = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
CotisationsByMembreLoaded copyWith({
|
||||||
|
String? membreId,
|
||||||
|
List<CotisationModel>? cotisations,
|
||||||
|
bool? hasReachedMax,
|
||||||
|
int? currentPage,
|
||||||
|
}) {
|
||||||
|
return CotisationsByMembreLoaded(
|
||||||
|
membreId: membreId ?? this.membreId,
|
||||||
|
cotisations: cotisations ?? this.cotisations,
|
||||||
|
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
||||||
|
currentPage: currentPage ?? this.currentPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [membreId, cotisations, hasReachedMax, currentPage];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État pour les cotisations en retard
|
||||||
|
class CotisationsEnRetardLoaded extends CotisationsState {
|
||||||
|
final List<CotisationModel> cotisations;
|
||||||
|
final bool hasReachedMax;
|
||||||
|
final int currentPage;
|
||||||
|
|
||||||
|
const CotisationsEnRetardLoaded({
|
||||||
|
required this.cotisations,
|
||||||
|
this.hasReachedMax = false,
|
||||||
|
this.currentPage = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
CotisationsEnRetardLoaded copyWith({
|
||||||
|
List<CotisationModel>? cotisations,
|
||||||
|
bool? hasReachedMax,
|
||||||
|
int? currentPage,
|
||||||
|
}) {
|
||||||
|
return CotisationsEnRetardLoaded(
|
||||||
|
cotisations: cotisations ?? this.cotisations,
|
||||||
|
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
||||||
|
currentPage: currentPage ?? this.currentPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [cotisations, hasReachedMax, currentPage];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État pour les résultats de recherche
|
||||||
|
class CotisationsSearchResults extends CotisationsState {
|
||||||
|
final List<CotisationModel> cotisations;
|
||||||
|
final Map<String, dynamic> searchCriteria;
|
||||||
|
final bool hasReachedMax;
|
||||||
|
final int currentPage;
|
||||||
|
|
||||||
|
const CotisationsSearchResults({
|
||||||
|
required this.cotisations,
|
||||||
|
required this.searchCriteria,
|
||||||
|
this.hasReachedMax = false,
|
||||||
|
this.currentPage = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
CotisationsSearchResults copyWith({
|
||||||
|
List<CotisationModel>? cotisations,
|
||||||
|
Map<String, dynamic>? searchCriteria,
|
||||||
|
bool? hasReachedMax,
|
||||||
|
int? currentPage,
|
||||||
|
}) {
|
||||||
|
return CotisationsSearchResults(
|
||||||
|
cotisations: cotisations ?? this.cotisations,
|
||||||
|
searchCriteria: searchCriteria ?? this.searchCriteria,
|
||||||
|
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
||||||
|
currentPage: currentPage ?? this.currentPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [cotisations, searchCriteria, hasReachedMax, currentPage];
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../../core/di/injection.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/widgets/coming_soon_page.dart';
|
||||||
|
import '../bloc/cotisations_bloc.dart';
|
||||||
|
import '../bloc/cotisations_event.dart';
|
||||||
|
import '../bloc/cotisations_state.dart';
|
||||||
|
import '../widgets/cotisation_card.dart';
|
||||||
|
import '../widgets/cotisations_stats_card.dart';
|
||||||
|
|
||||||
|
/// Page principale pour la liste des cotisations
|
||||||
|
class CotisationsListPage extends StatefulWidget {
|
||||||
|
const CotisationsListPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CotisationsListPage> createState() => _CotisationsListPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CotisationsListPageState extends State<CotisationsListPage> {
|
||||||
|
late final CotisationsBloc _cotisationsBloc;
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_cotisationsBloc = getIt<CotisationsBloc>();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
|
// Charger les données initiales
|
||||||
|
_cotisationsBloc.add(const LoadCotisations());
|
||||||
|
_cotisationsBloc.add(const LoadCotisationsStats());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
_cotisationsBloc.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_isBottom) {
|
||||||
|
final currentState = _cotisationsBloc.state;
|
||||||
|
if (currentState is CotisationsLoaded && !currentState.hasReachedMax) {
|
||||||
|
_cotisationsBloc.add(LoadCotisations(
|
||||||
|
page: currentState.currentPage + 1,
|
||||||
|
size: 20,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 50, 16, 16),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(20),
|
||||||
|
bottomRight: Radius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Cotisations',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search, color: Colors.white),
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Implémenter la recherche
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.filter_list, color: Colors.white),
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Implémenter les filtres
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Gérez les cotisations de vos membres',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadedState(CotisationsLoaded state) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
_cotisationsBloc.add(const LoadCotisations(refresh: true));
|
||||||
|
_cotisationsBloc.add(const LoadCotisationsStats());
|
||||||
|
},
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
slivers: [
|
||||||
|
// Statistiques
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: BlocBuilder<CotisationsBloc, CotisationsState>(
|
||||||
|
buildWhen: (previous, current) => current is CotisationsStatsLoaded,
|
||||||
|
builder: (context, statsState) {
|
||||||
|
if (statsState is CotisationsStatsLoaded) {
|
||||||
|
return CotisationsStatsCard(statistics: statsState.statistics);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Liste des cotisations
|
||||||
|
if (state.filteredCotisations.isEmpty)
|
||||||
|
const SliverFillRemaining(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.payment_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucune cotisation trouvée',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Commencez par créer une cotisation',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
if (index >= state.filteredCotisations.length) {
|
||||||
|
return state.hasReachedMax
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cotisation = state.filteredCotisations[index];
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
index == 0 ? 0 : 8,
|
||||||
|
16,
|
||||||
|
index == state.filteredCotisations.length - 1 ? 16 : 8,
|
||||||
|
),
|
||||||
|
child: CotisationCard(
|
||||||
|
cotisation: cotisation,
|
||||||
|
onTap: () {
|
||||||
|
// TODO: Naviguer vers le détail
|
||||||
|
},
|
||||||
|
onPay: () {
|
||||||
|
// TODO: Implémenter le paiement
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Paiement - En cours de développement'),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: state.filteredCotisations.length +
|
||||||
|
(state.hasReachedMax ? 0 : 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorState(CotisationsError state) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
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.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
state.message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
_cotisationsBloc.add(const LoadCotisations(refresh: true));
|
||||||
|
_cotisationsBloc.add(const LoadCotisationsStats());
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Réessayer'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../../core/models/cotisation_model.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Widget card pour afficher une cotisation
|
||||||
|
class CotisationCard extends StatelessWidget {
|
||||||
|
final CotisationModel cotisation;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final VoidCallback? onPay;
|
||||||
|
final VoidCallback? onEdit;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
|
||||||
|
const CotisationCard({
|
||||||
|
super.key,
|
||||||
|
required this.cotisation,
|
||||||
|
this.onTap,
|
||||||
|
this.onPay,
|
||||||
|
this.onEdit,
|
||||||
|
this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final currencyFormat = NumberFormat.currency(
|
||||||
|
locale: 'fr_FR',
|
||||||
|
symbol: 'FCFA',
|
||||||
|
decimalDigits: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
final dateFormat = DateFormat('dd/MM/yyyy', 'fr_FR');
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(
|
||||||
|
color: _getStatusColor().withOpacity(0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header avec statut et actions
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Statut badge
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getStatusColor().withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
cotisation.libelleStatut,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _getStatusColor(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// Actions
|
||||||
|
if (cotisation.statut == 'EN_ATTENTE' || cotisation.statut == 'EN_RETARD')
|
||||||
|
IconButton(
|
||||||
|
onPressed: onPay,
|
||||||
|
icon: const Icon(Icons.payment, size: 20),
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
tooltip: 'Payer',
|
||||||
|
),
|
||||||
|
if (onEdit != null)
|
||||||
|
IconButton(
|
||||||
|
onPressed: onEdit,
|
||||||
|
icon: const Icon(Icons.edit, size: 20),
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
tooltip: 'Modifier',
|
||||||
|
),
|
||||||
|
if (onDelete != null)
|
||||||
|
IconButton(
|
||||||
|
onPressed: onDelete,
|
||||||
|
icon: const Icon(Icons.delete, size: 20),
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
tooltip: 'Supprimer',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Informations principales
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Icône du type
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
cotisation.iconeTypeCotisation,
|
||||||
|
style: const TextStyle(fontSize: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Détails
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
cotisation.libelleTypeCotisation,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (cotisation.nomMembre != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
cotisation.nomMembre!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (cotisation.periode != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
cotisation.periode!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Montant
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
currencyFormat.format(cotisation.montantDu),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (cotisation.montantPaye > 0) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Payé: ${currencyFormat.format(cotisation.montantPaye)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Barre de progression du paiement
|
||||||
|
if (cotisation.montantPaye > 0 && !cotisation.isEntierementPayee) ...[
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Progression',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${cotisation.pourcentagePaiement.toStringAsFixed(0)}%',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: cotisation.pourcentagePaiement / 100,
|
||||||
|
backgroundColor: AppTheme.borderColor,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
cotisation.pourcentagePaiement >= 100
|
||||||
|
? AppTheme.successColor
|
||||||
|
: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Informations d'échéance
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.schedule,
|
||||||
|
size: 16,
|
||||||
|
color: cotisation.isEnRetard
|
||||||
|
? AppTheme.errorColor
|
||||||
|
: cotisation.echeanceProche
|
||||||
|
? AppTheme.warningColor
|
||||||
|
: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Échéance: ${dateFormat.format(cotisation.dateEcheance)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: cotisation.isEnRetard
|
||||||
|
? AppTheme.errorColor
|
||||||
|
: cotisation.echeanceProche
|
||||||
|
? AppTheme.warningColor
|
||||||
|
: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (cotisation.messageUrgence.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cotisation.isEnRetard
|
||||||
|
? AppTheme.errorColor.withOpacity(0.1)
|
||||||
|
: AppTheme.warningColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
cotisation.messageUrgence,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: cotisation.isEnRetard
|
||||||
|
? AppTheme.errorColor
|
||||||
|
: AppTheme.warningColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Référence
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.tag,
|
||||||
|
size: 16,
|
||||||
|
color: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Réf: ${cotisation.numeroReference}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getStatusColor() {
|
||||||
|
switch (cotisation.statut) {
|
||||||
|
case 'PAYEE':
|
||||||
|
return AppTheme.successColor;
|
||||||
|
case 'EN_ATTENTE':
|
||||||
|
return AppTheme.warningColor;
|
||||||
|
case 'EN_RETARD':
|
||||||
|
return AppTheme.errorColor;
|
||||||
|
case 'PARTIELLEMENT_PAYEE':
|
||||||
|
return AppTheme.infoColor;
|
||||||
|
case 'ANNULEE':
|
||||||
|
return AppTheme.textHint;
|
||||||
|
default:
|
||||||
|
return AppTheme.textSecondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Widget pour afficher les statistiques des cotisations
|
||||||
|
class CotisationsStatsCard extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> statistics;
|
||||||
|
|
||||||
|
const CotisationsStatsCard({
|
||||||
|
super.key,
|
||||||
|
required this.statistics,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final currencyFormat = NumberFormat.currency(
|
||||||
|
locale: 'fr_FR',
|
||||||
|
symbol: 'FCFA',
|
||||||
|
decimalDigits: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
final totalCotisations = statistics['totalCotisations'] as int? ?? 0;
|
||||||
|
final cotisationsPayees = statistics['cotisationsPayees'] as int? ?? 0;
|
||||||
|
final cotisationsEnRetard = statistics['cotisationsEnRetard'] as int? ?? 0;
|
||||||
|
final tauxPaiement = statistics['tauxPaiement'] as double? ?? 0.0;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Titre
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.accentColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.analytics,
|
||||||
|
size: 18,
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text(
|
||||||
|
'Statistiques des cotisations',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Grille des statistiques
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Total des cotisations
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
icon: Icons.receipt_long,
|
||||||
|
label: 'Total',
|
||||||
|
value: totalCotisations.toString(),
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Cotisations payées
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
icon: Icons.check_circle,
|
||||||
|
label: 'Payées',
|
||||||
|
value: cotisationsPayees.toString(),
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Cotisations en retard
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
icon: Icons.warning,
|
||||||
|
label: 'En retard',
|
||||||
|
value: cotisationsEnRetard.toString(),
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Taux de paiement
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
icon: Icons.trending_up,
|
||||||
|
label: 'Taux paiement',
|
||||||
|
value: '${tauxPaiement.toStringAsFixed(1)}%',
|
||||||
|
color: tauxPaiement >= 80
|
||||||
|
? AppTheme.successColor
|
||||||
|
: tauxPaiement >= 60
|
||||||
|
? AppTheme.warningColor
|
||||||
|
: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Barre de progression globale
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Progression globale',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${tauxPaiement.toStringAsFixed(1)}%',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: tauxPaiement / 100,
|
||||||
|
backgroundColor: AppTheme.borderColor,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
tauxPaiement >= 80
|
||||||
|
? AppTheme.successColor
|
||||||
|
: tauxPaiement >= 60
|
||||||
|
? AppTheme.warningColor
|
||||||
|
: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Montants si disponibles
|
||||||
|
if (statistics.containsKey('montantTotal') ||
|
||||||
|
statistics.containsKey('montantPaye')) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (statistics.containsKey('montantTotal')) ...[
|
||||||
|
Expanded(
|
||||||
|
child: _buildMoneyStatItem(
|
||||||
|
label: 'Montant total',
|
||||||
|
value: currencyFormat.format(
|
||||||
|
(statistics['montantTotal'] as num?)?.toDouble() ?? 0.0
|
||||||
|
),
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (statistics.containsKey('montantTotal') &&
|
||||||
|
statistics.containsKey('montantPaye'))
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
if (statistics.containsKey('montantPaye')) ...[
|
||||||
|
Expanded(
|
||||||
|
child: _buildMoneyStatItem(
|
||||||
|
label: 'Montant payé',
|
||||||
|
value: currencyFormat.format(
|
||||||
|
(statistics['montantPaye'] as num?)?.toDouble() ?? 0.0
|
||||||
|
),
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatItem({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 24,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMoneyStatItem({
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,24 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
|
||||||
import '../../../../shared/theme/app_theme.dart';
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
|
||||||
|
// Imports des nouveaux widgets refactorisés
|
||||||
|
import '../widgets/welcome/welcome_section_widget.dart';
|
||||||
|
import '../widgets/kpi/kpi_cards_widget.dart';
|
||||||
|
import '../widgets/actions/quick_actions_widget.dart';
|
||||||
|
import '../widgets/activities/recent_activities_widget.dart';
|
||||||
|
import '../widgets/charts/charts_analytics_widget.dart';
|
||||||
|
|
||||||
|
/// Page principale du tableau de bord UnionFlow
|
||||||
|
///
|
||||||
|
/// Affiche une vue d'ensemble complète de l'association avec :
|
||||||
|
/// - Section d'accueil personnalisée
|
||||||
|
/// - Indicateurs clés de performance (KPI)
|
||||||
|
/// - Actions rapides et gestion
|
||||||
|
/// - Flux d'activités en temps réel
|
||||||
|
/// - Analyses et tendances graphiques
|
||||||
|
///
|
||||||
|
/// Architecture modulaire avec widgets réutilisables pour une
|
||||||
|
/// maintenabilité optimale et une évolutivité facilitée.
|
||||||
class DashboardPage extends StatelessWidget {
|
class DashboardPage extends StatelessWidget {
|
||||||
const DashboardPage({super.key});
|
const DashboardPage({super.key});
|
||||||
|
|
||||||
@@ -16,11 +33,15 @@ class DashboardPage extends StatelessWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.notifications_outlined),
|
icon: const Icon(Icons.notifications_outlined),
|
||||||
onPressed: () {},
|
onPressed: () {
|
||||||
|
// TODO: Implémenter la navigation vers les notifications
|
||||||
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings_outlined),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
onPressed: () {},
|
onPressed: () {
|
||||||
|
// TODO: Implémenter la navigation vers les paramètres
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -30,646 +51,32 @@ class DashboardPage extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Message de bienvenue
|
// 1. ACCUEIL & CONTEXTE - Message de bienvenue personnalisé
|
||||||
_buildWelcomeSection(context),
|
const WelcomeSectionWidget(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Cartes KPI principales
|
// 2. VISION GLOBALE - Indicateurs clés de performance (KPI)
|
||||||
_buildKPICards(context),
|
// Vue d'ensemble immédiate de la santé de l'association
|
||||||
|
const KPICardsWidget(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Graphiques et statistiques
|
// 3. ACTIONS PRIORITAIRES - Actions rapides et gestion
|
||||||
_buildChartsSection(context),
|
// Accès direct aux tâches critiques quotidiennes
|
||||||
|
const QuickActionsWidget(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Actions rapides
|
// 4. SUIVI TEMPS RÉEL - Flux d'activités en direct
|
||||||
_buildQuickActions(context),
|
// Monitoring des événements récents et alertes
|
||||||
|
const RecentActivitiesWidget(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Activités récentes
|
// 5. ANALYSES APPROFONDIES - Graphiques et tendances
|
||||||
_buildRecentActivities(context),
|
// Analyses détaillées pour la prise de décision stratégique
|
||||||
|
const ChartsAnalyticsWidget(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Widget _buildWelcomeSection(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [AppTheme.primaryColor, AppTheme.primaryLight],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Bonjour !',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Voici un aperçu de votre association',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withOpacity(0.9),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: 60,
|
|
||||||
height: 60,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withOpacity(0.2),
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.dashboard,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 30,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildKPICards(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Indicateurs clés',
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildKPICard(
|
|
||||||
context,
|
|
||||||
'Membres',
|
|
||||||
'1,247',
|
|
||||||
'+5.2%',
|
|
||||||
Icons.people,
|
|
||||||
AppTheme.primaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _buildKPICard(
|
|
||||||
context,
|
|
||||||
'Revenus',
|
|
||||||
'€45,890',
|
|
||||||
'+12.8%',
|
|
||||||
Icons.euro,
|
|
||||||
AppTheme.successColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildKPICard(
|
|
||||||
context,
|
|
||||||
'Événements',
|
|
||||||
'23',
|
|
||||||
'+3',
|
|
||||||
Icons.event,
|
|
||||||
AppTheme.accentColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _buildKPICard(
|
|
||||||
context,
|
|
||||||
'Cotisations',
|
|
||||||
'89.5%',
|
|
||||||
'+2.1%',
|
|
||||||
Icons.payments,
|
|
||||||
AppTheme.infoColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildKPICard(
|
|
||||||
BuildContext context,
|
|
||||||
String title,
|
|
||||||
String value,
|
|
||||||
String change,
|
|
||||||
IconData icon,
|
|
||||||
Color color,
|
|
||||||
) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.05),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
icon,
|
|
||||||
color: color,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.successColor.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
change,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppTheme.successColor,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildChartsSection(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Analyses',
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildLineChart(context),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _buildPieChart(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildLineChart(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
height: 200,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.05),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Évolution des membres',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Expanded(
|
|
||||||
child: LineChart(
|
|
||||||
LineChartData(
|
|
||||||
gridData: const FlGridData(show: false),
|
|
||||||
titlesData: const FlTitlesData(show: false),
|
|
||||||
borderData: FlBorderData(show: false),
|
|
||||||
lineBarsData: [
|
|
||||||
LineChartBarData(
|
|
||||||
spots: const [
|
|
||||||
FlSpot(0, 1000),
|
|
||||||
FlSpot(1, 1050),
|
|
||||||
FlSpot(2, 1100),
|
|
||||||
FlSpot(3, 1180),
|
|
||||||
FlSpot(4, 1247),
|
|
||||||
],
|
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
barWidth: 3,
|
|
||||||
isStrokeCapRound: true,
|
|
||||||
dotData: const FlDotData(show: false),
|
|
||||||
belowBarData: BarAreaData(
|
|
||||||
show: true,
|
|
||||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPieChart(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
height: 200,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.05),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Répartition des membres',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Expanded(
|
|
||||||
child: PieChart(
|
|
||||||
PieChartData(
|
|
||||||
sectionsSpace: 0,
|
|
||||||
centerSpaceRadius: 40,
|
|
||||||
sections: [
|
|
||||||
PieChartSectionData(
|
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
value: 45,
|
|
||||||
title: '45%',
|
|
||||||
radius: 50,
|
|
||||||
titleStyle: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PieChartSectionData(
|
|
||||||
color: AppTheme.secondaryColor,
|
|
||||||
value: 30,
|
|
||||||
title: '30%',
|
|
||||||
radius: 50,
|
|
||||||
titleStyle: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PieChartSectionData(
|
|
||||||
color: AppTheme.accentColor,
|
|
||||||
value: 25,
|
|
||||||
title: '25%',
|
|
||||||
radius: 50,
|
|
||||||
titleStyle: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildQuickActions(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Actions rapides',
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildActionCard(
|
|
||||||
context,
|
|
||||||
'Nouveau membre',
|
|
||||||
'Ajouter un membre',
|
|
||||||
Icons.person_add,
|
|
||||||
AppTheme.primaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _buildActionCard(
|
|
||||||
context,
|
|
||||||
'Créer événement',
|
|
||||||
'Organiser un événement',
|
|
||||||
Icons.event_available,
|
|
||||||
AppTheme.secondaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildActionCard(
|
|
||||||
context,
|
|
||||||
'Suivi cotisations',
|
|
||||||
'Gérer les cotisations',
|
|
||||||
Icons.payment,
|
|
||||||
AppTheme.accentColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _buildActionCard(
|
|
||||||
context,
|
|
||||||
'Rapports',
|
|
||||||
'Générer des rapports',
|
|
||||||
Icons.analytics,
|
|
||||||
AppTheme.infoColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActionCard(
|
|
||||||
BuildContext context,
|
|
||||||
String title,
|
|
||||||
String subtitle,
|
|
||||||
IconData icon,
|
|
||||||
Color color,
|
|
||||||
) {
|
|
||||||
return InkWell(
|
|
||||||
onTap: () {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('$title - En cours de développement'),
|
|
||||||
backgroundColor: color,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: color.withOpacity(0.2)),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.05),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
icon,
|
|
||||||
color: color,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildRecentActivities(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Activités récentes',
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {},
|
|
||||||
child: const Text('Voir tout'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.05),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
_buildActivityItem(
|
|
||||||
'Nouveau membre inscrit',
|
|
||||||
'Marie Dupont a rejoint l\'association',
|
|
||||||
Icons.person_add,
|
|
||||||
AppTheme.successColor,
|
|
||||||
'Il y a 2h',
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
_buildActivityItem(
|
|
||||||
'Cotisation reçue',
|
|
||||||
'Jean Martin a payé sa cotisation annuelle',
|
|
||||||
Icons.payment,
|
|
||||||
AppTheme.primaryColor,
|
|
||||||
'Il y a 4h',
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
_buildActivityItem(
|
|
||||||
'Événement créé',
|
|
||||||
'Assemblée générale 2024 programmée',
|
|
||||||
Icons.event,
|
|
||||||
AppTheme.accentColor,
|
|
||||||
'Hier',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActivityItem(
|
|
||||||
String title,
|
|
||||||
String description,
|
|
||||||
IconData icon,
|
|
||||||
Color color,
|
|
||||||
String time,
|
|
||||||
) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
icon,
|
|
||||||
color: color,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
description,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
time,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppTheme.textHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,485 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../../../shared/theme/app_theme.dart';
|
|
||||||
|
|
||||||
import '../widgets/clickable_kpi_card.dart';
|
|
||||||
import '../widgets/chart_card.dart';
|
|
||||||
import '../widgets/activity_feed.dart';
|
|
||||||
import '../widgets/quick_actions_grid.dart';
|
|
||||||
import '../widgets/navigation_cards.dart';
|
|
||||||
|
|
||||||
class EnhancedDashboard extends StatefulWidget {
|
|
||||||
final Function(int)? onNavigateToTab;
|
|
||||||
|
|
||||||
const EnhancedDashboard({
|
|
||||||
super.key,
|
|
||||||
this.onNavigateToTab,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<EnhancedDashboard> createState() => _EnhancedDashboardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EnhancedDashboardState extends State<EnhancedDashboard> {
|
|
||||||
final PageController _pageController = PageController();
|
|
||||||
int _currentPage = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_pageController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: AppTheme.backgroundLight,
|
|
||||||
body: CustomScrollView(
|
|
||||||
slivers: [
|
|
||||||
_buildAppBar(),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_buildWelcomeCard(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_buildKPISection(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_buildChartsSection(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
NavigationCards(
|
|
||||||
onNavigateToTab: widget.onNavigateToTab,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
const QuickActionsGrid(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
const ActivityFeed(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAppBar() {
|
|
||||||
return SliverAppBar(
|
|
||||||
expandedHeight: 120,
|
|
||||||
floating: false,
|
|
||||||
pinned: true,
|
|
||||||
backgroundColor: AppTheme.primaryColor,
|
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
|
||||||
title: const Text(
|
|
||||||
'Tableau de bord',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
background: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [AppTheme.primaryColor, AppTheme.primaryDark],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.notifications_outlined),
|
|
||||||
onPressed: () => _showNotifications(),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
onPressed: () => _refreshData(),
|
|
||||||
),
|
|
||||||
PopupMenuButton<String>(
|
|
||||||
icon: const Icon(Icons.more_vert),
|
|
||||||
onSelected: _handleMenuSelection,
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: 'settings',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.settings),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Paramètres'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: 'export',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.download),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Exporter'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: 'help',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.help),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Aide'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildWelcomeCard() {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [AppTheme.primaryColor, AppTheme.primaryLight],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppTheme.primaryColor.withOpacity(0.3),
|
|
||||||
blurRadius: 20,
|
|
||||||
offset: const Offset(0, 8),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Bonjour !',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Découvrez les dernières statistiques de votre association',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withOpacity(0.9),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 6,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withOpacity(0.2),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.trending_up,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'+12% ce mois',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withOpacity(0.2),
|
|
||||||
borderRadius: BorderRadius.circular(40),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.dashboard_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 40,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildKPISection() {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Indicateurs clés',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(Icons.analytics, size: 16),
|
|
||||||
label: const Text('Analyse détaillée'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SizedBox(
|
|
||||||
height: 180,
|
|
||||||
child: PageView(
|
|
||||||
controller: _pageController,
|
|
||||||
onPageChanged: (index) {
|
|
||||||
setState(() {
|
|
||||||
_currentPage = index;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
_buildKPIPage1(),
|
|
||||||
_buildKPIPage2(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
_buildPageIndicator(0),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_buildPageIndicator(1),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildKPIPage1() {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ClickableKPICard(
|
|
||||||
title: 'Membres actifs',
|
|
||||||
value: '1,247',
|
|
||||||
change: '+5.2%',
|
|
||||||
icon: Icons.people,
|
|
||||||
color: AppTheme.secondaryColor,
|
|
||||||
actionText: 'Gérer',
|
|
||||||
onTap: () => widget.onNavigateToTab?.call(1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ClickableKPICard(
|
|
||||||
title: 'Revenus mensuel',
|
|
||||||
value: '€45,890',
|
|
||||||
change: '+12.8%',
|
|
||||||
icon: Icons.euro,
|
|
||||||
color: AppTheme.successColor,
|
|
||||||
actionText: 'Finances',
|
|
||||||
onTap: () => _showFinancesMessage(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildKPIPage2() {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ClickableKPICard(
|
|
||||||
title: 'Événements',
|
|
||||||
value: '23',
|
|
||||||
change: '+3',
|
|
||||||
icon: Icons.event,
|
|
||||||
color: AppTheme.warningColor,
|
|
||||||
actionText: 'Planifier',
|
|
||||||
onTap: () => widget.onNavigateToTab?.call(3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ClickableKPICard(
|
|
||||||
title: 'Taux cotisation',
|
|
||||||
value: '89.5%',
|
|
||||||
change: '+2.1%',
|
|
||||||
icon: Icons.payments,
|
|
||||||
color: AppTheme.accentColor,
|
|
||||||
actionText: 'Gérer',
|
|
||||||
onTap: () => widget.onNavigateToTab?.call(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPageIndicator(int index) {
|
|
||||||
return AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
width: _currentPage == index ? 20 : 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _currentPage == index
|
|
||||||
? AppTheme.primaryColor
|
|
||||||
: AppTheme.primaryColor.withOpacity(0.3),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildChartsSection() {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Analyses et tendances',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ChartCard(
|
|
||||||
title: 'Évolution des membres',
|
|
||||||
subtitle: 'Croissance sur 6 mois',
|
|
||||||
chart: const MembershipChart(),
|
|
||||||
onTap: () => widget.onNavigateToTab?.call(1),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ChartCard(
|
|
||||||
title: 'Répartition',
|
|
||||||
subtitle: 'Par catégorie',
|
|
||||||
chart: const CategoryChart(),
|
|
||||||
onTap: () => widget.onNavigateToTab?.call(1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ChartCard(
|
|
||||||
title: 'Revenus',
|
|
||||||
subtitle: 'Évolution mensuelle',
|
|
||||||
chart: const RevenueChart(),
|
|
||||||
onTap: () => _showFinancesMessage(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showNotifications() {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Container(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Notifications',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.warning, color: AppTheme.warningColor),
|
|
||||||
title: const Text('3 cotisations en retard'),
|
|
||||||
subtitle: const Text('Nécessite votre attention'),
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.event, color: AppTheme.accentColor),
|
|
||||||
title: const Text('Assemblée générale'),
|
|
||||||
subtitle: const Text('Dans 5 jours'),
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.check_circle, color: AppTheme.successColor),
|
|
||||||
title: const Text('Rapport mensuel'),
|
|
||||||
subtitle: const Text('Prêt à être envoyé'),
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _refreshData() {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Données actualisées'),
|
|
||||||
backgroundColor: AppTheme.successColor,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleMenuSelection(String value) {
|
|
||||||
switch (value) {
|
|
||||||
case 'settings':
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Paramètres - En développement')),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'export':
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Export - En développement')),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'help':
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Aide - En développement')),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showFinancesMessage() {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Module Finances - Prochainement disponible'),
|
|
||||||
backgroundColor: AppTheme.successColor,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../../shared/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Widget de carte d'action rapide réutilisable
|
||||||
|
///
|
||||||
|
/// Affiche une action cliquable avec:
|
||||||
|
/// - Icône colorée dans un conteneur arrondi
|
||||||
|
/// - Titre principal
|
||||||
|
/// - Sous-titre descriptif
|
||||||
|
/// - Interaction tactile avec feedback visuel
|
||||||
|
/// - Callback personnalisable pour l'action
|
||||||
|
class ActionCardWidget extends StatelessWidget {
|
||||||
|
/// Titre de l'action
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Description de l'action
|
||||||
|
final String subtitle;
|
||||||
|
|
||||||
|
/// Icône représentative
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Couleur thématique de l'action
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
/// Callback exécuté lors du tap
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const ActionCardWidget({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap ?? () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('$title - En cours de développement'),
|
||||||
|
backgroundColor: color,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: color.withOpacity(0.2)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: color,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../../shared/theme/app_theme.dart';
|
||||||
|
import 'action_card_widget.dart';
|
||||||
|
|
||||||
|
/// Widget de section des actions rapides et de gestion
|
||||||
|
///
|
||||||
|
/// Affiche une grille d'actions rapides organisées par catégories:
|
||||||
|
/// - Actions principales (nouveau membre, créer événement)
|
||||||
|
/// - Gestion financière (encaisser cotisation, relances)
|
||||||
|
/// - Communication (messages, convocations)
|
||||||
|
/// - Rapports et conformité (OHADA, exports)
|
||||||
|
/// - Urgences et support (alertes, assistance)
|
||||||
|
class QuickActionsWidget extends StatelessWidget {
|
||||||
|
const QuickActionsWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Actions rapides & Gestion',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Première ligne - Actions principales
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ActionCardWidget(
|
||||||
|
title: 'Nouveau membre',
|
||||||
|
subtitle: 'Inscription rapide',
|
||||||
|
icon: Icons.person_add,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ActionCardWidget(
|
||||||
|
title: 'Créer événement',
|
||||||
|
subtitle: 'Organiser activité',
|
||||||
|
icon: Icons.event_available,
|
||||||
|
color: AppTheme.secondaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Deuxième ligne - Gestion financière
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ActionCardWidget(
|
||||||
|
title: 'Encaisser cotisation',
|
||||||
|
subtitle: 'Paiement immédiat',
|
||||||
|
icon: Icons.payment,
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ActionCardWidget(
|
||||||
|
title: 'Relances impayés',
|
||||||
|
subtitle: 'Notifications SMS',
|
||||||
|
icon: Icons.notifications_active,
|
||||||
|
color: AppTheme.warningColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Troisième ligne - Communication
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ActionCardWidget(
|
||||||
|
title: 'Message groupe',
|
||||||
|
subtitle: 'Diffusion WhatsApp',
|
||||||
|
icon: Icons.message,
|
||||||
|
color: const Color(0xFF25D366),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ActionCardWidget(
|
||||||
|
title: 'Convoquer AG',
|
||||||
|
subtitle: 'Assemblée générale',
|
||||||
|
icon: Icons.groups,
|
||||||
|
color: const Color(0xFF9C27B0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Quatrième ligne - Rapports et conformité
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ActionCardWidget(
|
||||||
|
title: 'Rapport OHADA',
|
||||||
|
subtitle: 'Conformité légale',
|
||||||
|
icon: Icons.gavel,
|
||||||
|
color: const Color(0xFF795548),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ActionCardWidget(
|
||||||
|
title: 'Export données',
|
||||||
|
subtitle: 'Sauvegarde Excel',
|
||||||
|
icon: Icons.file_download,
|
||||||
|
color: AppTheme.infoColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Cinquième ligne - Urgences et support
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ActionCardWidget(
|
||||||
|
title: 'Alerte urgente',
|
||||||
|
subtitle: 'Notification critique',
|
||||||
|
icon: Icons.emergency,
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ActionCardWidget(
|
||||||
|
title: 'Support technique',
|
||||||
|
subtitle: 'Assistance UnionFlow',
|
||||||
|
icon: Icons.support_agent,
|
||||||
|
color: const Color(0xFF607D8B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../../shared/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Widget d'élément d'activité récente réutilisable
|
||||||
|
///
|
||||||
|
/// Affiche une activité avec:
|
||||||
|
/// - Icône colorée avec indicateur "nouveau" optionnel
|
||||||
|
/// - Titre et description
|
||||||
|
/// - Horodatage avec mise en évidence pour les nouveaux éléments
|
||||||
|
/// - Badge "NOUVEAU" pour les activités récentes
|
||||||
|
/// - Indicateur visuel pour les nouvelles activités
|
||||||
|
class ActivityItemWidget extends StatelessWidget {
|
||||||
|
/// Titre de l'activité
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Description détaillée de l'activité
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
/// Icône représentative
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Couleur thématique
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
/// Horodatage de l'activité
|
||||||
|
final String time;
|
||||||
|
|
||||||
|
/// Indique si l'activité est nouvelle
|
||||||
|
final bool isNew;
|
||||||
|
|
||||||
|
const ActivityItemWidget({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.time,
|
||||||
|
this.isNew = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: color,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isNew)
|
||||||
|
Positioned(
|
||||||
|
top: -2,
|
||||||
|
right: -2,
|
||||||
|
child: Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: isNew ? FontWeight.w700 : FontWeight.w600,
|
||||||
|
color: isNew ? AppTheme.textPrimary : AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isNew)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'NOUVEAU',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: isNew ? AppTheme.textPrimary : AppTheme.textSecondary,
|
||||||
|
fontWeight: isNew ? FontWeight.w500 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
time,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: isNew ? AppTheme.primaryColor : AppTheme.textHint,
|
||||||
|
fontWeight: isNew ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isNew)
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
if (isNew)
|
||||||
|
const Icon(
|
||||||
|
Icons.fiber_new,
|
||||||
|
size: 12,
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../../shared/theme/app_theme.dart';
|
||||||
|
import 'activity_item_widget.dart';
|
||||||
|
|
||||||
|
/// Widget de section des activités récentes en temps réel
|
||||||
|
///
|
||||||
|
/// Affiche un flux d'activités en temps réel avec:
|
||||||
|
/// - En-tête avec indicateur "Live" et bouton "Tout voir"
|
||||||
|
/// - Liste d'activités avec indicateurs visuels pour les nouveaux éléments
|
||||||
|
/// - Séparateurs entre les éléments
|
||||||
|
/// - Horodatage précis pour chaque activité
|
||||||
|
/// - Icônes et couleurs thématiques par type d'activité
|
||||||
|
class RecentActivitiesWidget extends StatelessWidget {
|
||||||
|
const RecentActivitiesWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Flux d\'activités en temps réel',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.successColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
const Text(
|
||||||
|
'Live',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Tout',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ActivityItemWidget(
|
||||||
|
title: 'Paiement Mobile Money reçu',
|
||||||
|
description: 'Kouassi Yao - 25,000 FCFA via Orange Money',
|
||||||
|
icon: Icons.phone_android,
|
||||||
|
color: const Color(0xFFFF9800),
|
||||||
|
time: 'Il y a 3 min',
|
||||||
|
isNew: true,
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ActivityItemWidget(
|
||||||
|
title: 'Nouveau membre validé',
|
||||||
|
description: 'Adjoua Marie inscrite depuis Abidjan',
|
||||||
|
icon: Icons.person_add,
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
time: 'Il y a 15 min',
|
||||||
|
isNew: true,
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ActivityItemWidget(
|
||||||
|
title: 'Relance automatique envoyée',
|
||||||
|
description: '12 SMS de rappel cotisations expédiés',
|
||||||
|
icon: Icons.sms,
|
||||||
|
color: AppTheme.infoColor,
|
||||||
|
time: 'Il y a 1h',
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ActivityItemWidget(
|
||||||
|
title: 'Rapport OHADA généré',
|
||||||
|
description: 'Bilan financier T4 2024 exporté',
|
||||||
|
icon: Icons.description,
|
||||||
|
color: const Color(0xFF795548),
|
||||||
|
time: 'Il y a 2h',
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ActivityItemWidget(
|
||||||
|
title: 'Événement: Forte participation',
|
||||||
|
description: 'AG Extraordinaire - 89% de présence',
|
||||||
|
icon: Icons.trending_up,
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
time: 'Il y a 3h',
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ActivityItemWidget(
|
||||||
|
title: 'Alerte: Cotisations en retard',
|
||||||
|
description: '23 membres avec +30 jours de retard',
|
||||||
|
icon: Icons.warning,
|
||||||
|
color: AppTheme.warningColor,
|
||||||
|
time: 'Il y a 4h',
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ActivityItemWidget(
|
||||||
|
title: 'Synchronisation réussie',
|
||||||
|
description: 'Données sauvegardées sur le cloud',
|
||||||
|
icon: Icons.cloud_done,
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
time: 'Il y a 6h',
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ActivityItemWidget(
|
||||||
|
title: 'Message diffusé',
|
||||||
|
description: 'Info COVID-19 envoyée à 1,247 membres',
|
||||||
|
icon: Icons.campaign,
|
||||||
|
color: const Color(0xFF9C27B0),
|
||||||
|
time: 'Hier 18:30',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../common/section_header_widget.dart';
|
||||||
|
|
||||||
|
/// Widget de section des analyses et tendances avec graphiques
|
||||||
|
///
|
||||||
|
/// Affiche tous les graphiques d'analyse en une seule colonne:
|
||||||
|
/// - Évolution des membres actifs (ligne)
|
||||||
|
/// - Répartition des cotisations (camembert)
|
||||||
|
/// - Revenus par source (barres)
|
||||||
|
/// - Cotisations par mois (barres)
|
||||||
|
/// - Engagement des membres (radar)
|
||||||
|
/// - Tendances géographiques (carte)
|
||||||
|
/// - Analyse comparative (barres groupées)
|
||||||
|
///
|
||||||
|
/// Chaque graphique est optimisé pour l'affichage mobile
|
||||||
|
/// avec des détails enrichis et des légendes complètes.
|
||||||
|
class ChartsAnalyticsWidget extends StatelessWidget {
|
||||||
|
const ChartsAnalyticsWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SectionHeaderWidget(title: 'Analyses & Tendances'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Graphiques d'analyse - Une seule colonne pour exploiter toute la largeur
|
||||||
|
_buildLineChart(context),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildPieChart(context),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildRevenueChart(context),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildCotisationsChart(context),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildEngagementChart(context),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTrendsChart(context),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildGeographicChart(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Graphique d'évolution des membres actifs (ligne)
|
||||||
|
Widget _buildLineChart(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 280,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// En-tête enrichi avec icône et métriques
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.trending_up,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Évolution des membres actifs',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
const Text(
|
||||||
|
'Croissance sur 5 mois • +24.7% (+247 membres)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.successColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.trending_up,
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
size: 12,
|
||||||
|
),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'+24.7%',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Placeholder pour le graphique
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.show_chart,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Graphique d\'évolution des membres',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Graphique de répartition des cotisations (camembert)
|
||||||
|
Widget _buildPieChart(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 280,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// En-tête enrichi
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.accentColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.pie_chart,
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Répartition des cotisations',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Par statut de paiement • 1,247 membres total',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Placeholder pour le graphique camembert
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.accentColor.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.accentColor.withOpacity(0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.donut_small,
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Graphique camembert des cotisations',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder pour les autres graphiques
|
||||||
|
Widget _buildRevenueChart(BuildContext context) {
|
||||||
|
return _buildPlaceholderChart(
|
||||||
|
'Revenus par source',
|
||||||
|
'Analyse mensuelle • 2,845,000 FCFA total',
|
||||||
|
Icons.bar_chart,
|
||||||
|
AppTheme.successColor,
|
||||||
|
'Graphique des revenus par source',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCotisationsChart(BuildContext context) {
|
||||||
|
return _buildPlaceholderChart(
|
||||||
|
'Cotisations par mois',
|
||||||
|
'Évolution sur 12 mois • Tendance positive',
|
||||||
|
Icons.assessment,
|
||||||
|
AppTheme.infoColor,
|
||||||
|
'Graphique des cotisations mensuelles',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEngagementChart(BuildContext context) {
|
||||||
|
return _buildPlaceholderChart(
|
||||||
|
'Engagement des membres',
|
||||||
|
'Analyse multi-critères • Score global 85/100',
|
||||||
|
Icons.radar,
|
||||||
|
const Color(0xFF9C27B0),
|
||||||
|
'Graphique radar d\'engagement',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrendsChart(BuildContext context) {
|
||||||
|
return _buildPlaceholderChart(
|
||||||
|
'Tendances comparatives',
|
||||||
|
'Comparaison avec période précédente',
|
||||||
|
Icons.compare_arrows,
|
||||||
|
AppTheme.warningColor,
|
||||||
|
'Graphique de tendances comparatives',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGeographicChart(BuildContext context) {
|
||||||
|
return _buildPlaceholderChart(
|
||||||
|
'Répartition géographique',
|
||||||
|
'Membres par région • Côte d\'Ivoire',
|
||||||
|
Icons.map,
|
||||||
|
const Color(0xFF795548),
|
||||||
|
'Carte géographique des membres',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget placeholder générique pour les graphiques
|
||||||
|
Widget _buildPlaceholderChart(
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
IconData icon,
|
||||||
|
Color color,
|
||||||
|
String description,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
height: 280,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: color,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
color: color,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../../shared/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Widget d'en-tête de section réutilisable
|
||||||
|
///
|
||||||
|
/// Affiche un titre de section avec style cohérent
|
||||||
|
/// utilisé dans toutes les sections du dashboard.
|
||||||
|
class SectionHeaderWidget extends StatelessWidget {
|
||||||
|
/// Titre de la section
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Style de texte personnalisé (optionnel)
|
||||||
|
final TextStyle? textStyle;
|
||||||
|
|
||||||
|
const SectionHeaderWidget({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.textStyle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: textStyle ?? Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../../shared/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Widget de carte KPI réutilisable avec détails enrichis
|
||||||
|
///
|
||||||
|
/// Affiche un indicateur de performance clé avec:
|
||||||
|
/// - Icône et badge de tendance coloré
|
||||||
|
/// - Valeur principale avec objectif optionnel
|
||||||
|
/// - Titre avec période
|
||||||
|
/// - Description détaillée
|
||||||
|
/// - Points de détail sous forme de puces
|
||||||
|
/// - Horodatage de dernière mise à jour
|
||||||
|
class KPICardWidget extends StatelessWidget {
|
||||||
|
/// Titre de l'indicateur
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Valeur principale affichée
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
/// Changement/tendance (ex: "+5.2%", "-3.1%")
|
||||||
|
final String change;
|
||||||
|
|
||||||
|
/// Icône représentative
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Couleur thématique de la carte
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
/// Description détaillée optionnelle
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
|
/// Période de référence (ex: "30j", "Mois")
|
||||||
|
final String? period;
|
||||||
|
|
||||||
|
/// Objectif cible optionnel
|
||||||
|
final String? target;
|
||||||
|
|
||||||
|
/// Horodatage de dernière mise à jour
|
||||||
|
final String? lastUpdate;
|
||||||
|
|
||||||
|
/// Liste de détails supplémentaires (max 3)
|
||||||
|
final List<String>? details;
|
||||||
|
|
||||||
|
const KPICardWidget({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.change,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
this.subtitle,
|
||||||
|
this.period,
|
||||||
|
this.target,
|
||||||
|
this.lastUpdate,
|
||||||
|
this.details,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// En-tête avec icône et badge de tendance
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: color,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getChangeColor(change).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getChangeIcon(change),
|
||||||
|
color: _getChangeColor(change),
|
||||||
|
size: 12,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
change,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _getChangeColor(change),
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Valeur principale
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (target != null)
|
||||||
|
Text(
|
||||||
|
'/ $target',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
|
// Titre et période
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (period != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
period!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Description détaillée
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Détails supplémentaires sous forme de puces
|
||||||
|
if (details != null && details!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: details!.take(3).map((detail) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 3),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 4),
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.6),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
detail,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: AppTheme.textSecondary.withOpacity(0.8),
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Dernière mise à jour
|
||||||
|
if (lastUpdate != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 10,
|
||||||
|
color: AppTheme.textSecondary.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Mis à jour: $lastUpdate',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
color: AppTheme.textSecondary.withOpacity(0.5),
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Détermine la couleur du badge de changement selon la valeur
|
||||||
|
Color _getChangeColor(String change) {
|
||||||
|
if (change.startsWith('+')) {
|
||||||
|
return AppTheme.successColor;
|
||||||
|
} else if (change.startsWith('-')) {
|
||||||
|
return AppTheme.errorColor;
|
||||||
|
} else {
|
||||||
|
return AppTheme.textSecondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Détermine l'icône du badge de changement selon la valeur
|
||||||
|
IconData _getChangeIcon(String change) {
|
||||||
|
if (change.startsWith('+')) {
|
||||||
|
return Icons.trending_up;
|
||||||
|
} else if (change.startsWith('-')) {
|
||||||
|
return Icons.trending_down;
|
||||||
|
} else {
|
||||||
|
return Icons.trending_flat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../../shared/theme/app_theme.dart';
|
||||||
|
import 'kpi_card_widget.dart';
|
||||||
|
|
||||||
|
/// Widget de section des cartes KPI principales
|
||||||
|
///
|
||||||
|
/// Affiche les 8 indicateurs clés de performance principaux
|
||||||
|
/// en une seule colonne pour optimiser l'utilisation de l'espace écran.
|
||||||
|
/// Chaque KPI contient des détails enrichis et des informations contextuelles.
|
||||||
|
class KPICardsWidget extends StatelessWidget {
|
||||||
|
const KPICardsWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Indicateurs clés de performance',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Indicateurs principaux - Une seule colonne pour exploiter toute la largeur
|
||||||
|
KPICardWidget(
|
||||||
|
title: 'Membres Actifs',
|
||||||
|
value: '1,247',
|
||||||
|
change: '+5.2%',
|
||||||
|
icon: Icons.people,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
subtitle: 'Base de cotisants actifs avec droits de vote et participation aux décisions',
|
||||||
|
period: '30j',
|
||||||
|
target: '1,300',
|
||||||
|
lastUpdate: 'il y a 2h',
|
||||||
|
details: const [
|
||||||
|
'892 membres à jour de cotisation (71.5%)',
|
||||||
|
'355 nouveaux membres cette année',
|
||||||
|
'23 membres en période d\'essai de 3 mois',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
KPICardWidget(
|
||||||
|
title: 'Revenus Totaux',
|
||||||
|
value: '2,845,000 FCFA',
|
||||||
|
change: '+12.8%',
|
||||||
|
icon: Icons.account_balance_wallet,
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
subtitle: 'Ensemble des revenus générés incluant cotisations, événements et subventions',
|
||||||
|
period: 'Mois',
|
||||||
|
target: '3,200,000 FCFA',
|
||||||
|
lastUpdate: 'il y a 1h',
|
||||||
|
details: const [
|
||||||
|
'1,950,000 FCFA de cotisations mensuelles (68.5%)',
|
||||||
|
'645,000 FCFA d\'activités et événements (22.7%)',
|
||||||
|
'250,000 FCFA de dons et subventions (8.8%)',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
KPICardWidget(
|
||||||
|
title: 'Événements Actifs',
|
||||||
|
value: '23',
|
||||||
|
change: '+3',
|
||||||
|
icon: Icons.event,
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
subtitle: 'Événements planifiés, formations professionnelles et activités sociales',
|
||||||
|
period: 'Mois',
|
||||||
|
target: '25',
|
||||||
|
lastUpdate: 'il y a 3h',
|
||||||
|
details: const [
|
||||||
|
'8 formations professionnelles et techniques',
|
||||||
|
'9 événements sociaux et culturels',
|
||||||
|
'6 assemblées générales et réunions',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
KPICardWidget(
|
||||||
|
title: 'Taux de Participation',
|
||||||
|
value: '78.3%',
|
||||||
|
change: '+2.1%',
|
||||||
|
icon: Icons.groups,
|
||||||
|
color: const Color(0xFF2196F3), // Blue
|
||||||
|
subtitle: 'Pourcentage de membres participant activement aux événements et décisions',
|
||||||
|
period: 'Trim.',
|
||||||
|
target: '85%',
|
||||||
|
lastUpdate: 'il y a 4h',
|
||||||
|
details: const [
|
||||||
|
'158 membres en retard de paiement',
|
||||||
|
'45,000 FCFA de frais de relance économisés',
|
||||||
|
'Amélioration de 12% par rapport au trimestre précédent',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
KPICardWidget(
|
||||||
|
title: 'Nouveaux Membres (30j)',
|
||||||
|
value: '47',
|
||||||
|
change: '+18.5%',
|
||||||
|
icon: Icons.person_add,
|
||||||
|
color: const Color(0xFF9C27B0), // Purple
|
||||||
|
subtitle: 'Nouvelles adhésions validées par le comité d\'admission',
|
||||||
|
period: '30j',
|
||||||
|
target: '50',
|
||||||
|
lastUpdate: 'il y a 30min',
|
||||||
|
details: const [
|
||||||
|
'28 adhésions individuelles (59.6%)',
|
||||||
|
'12 adhésions familiales (25.5%)',
|
||||||
|
'7 adhésions d\'entreprises partenaires (14.9%)',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
KPICardWidget(
|
||||||
|
title: 'Montant en Attente',
|
||||||
|
value: '785,000 FCFA',
|
||||||
|
change: '-5.2%',
|
||||||
|
icon: Icons.schedule,
|
||||||
|
color: AppTheme.warningColor,
|
||||||
|
subtitle: 'Montants promis en attente d\'encaissement ou de validation administrative',
|
||||||
|
period: 'Total',
|
||||||
|
lastUpdate: 'il y a 1h',
|
||||||
|
details: const [
|
||||||
|
'450,000 FCFA de promesses de dons (57.3%)',
|
||||||
|
'235,000 FCFA de cotisations promises (29.9%)',
|
||||||
|
'100,000 FCFA de subventions en cours (12.8%)',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
KPICardWidget(
|
||||||
|
title: 'Cotisations en Retard',
|
||||||
|
value: '156',
|
||||||
|
change: '+8.3%',
|
||||||
|
icon: Icons.access_time,
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
subtitle: 'Membres en situation d\'impayé nécessitant un suivi personnalisé',
|
||||||
|
period: '+30j',
|
||||||
|
lastUpdate: 'il y a 2h',
|
||||||
|
details: const [
|
||||||
|
'89 retards de 1-3 mois (57.1%)',
|
||||||
|
'45 retards de 3-6 mois (28.8%)',
|
||||||
|
'22 retards de plus de 6 mois (14.1%)',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
KPICardWidget(
|
||||||
|
title: 'Score Global de Performance',
|
||||||
|
value: '85/100',
|
||||||
|
change: '+3 pts',
|
||||||
|
icon: Icons.assessment,
|
||||||
|
color: const Color(0xFF00BCD4), // Cyan
|
||||||
|
subtitle: 'Évaluation globale basée sur 15 indicateurs de santé organisationnelle',
|
||||||
|
period: 'Mois',
|
||||||
|
target: '90/100',
|
||||||
|
lastUpdate: 'il y a 6h',
|
||||||
|
details: const [
|
||||||
|
'Finances: 92/100 (Excellent)',
|
||||||
|
'Participation: 78/100 (Bon)',
|
||||||
|
'Gouvernance: 85/100 (Très bon)',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../../shared/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Widget de section d'accueil personnalisé pour le dashboard
|
||||||
|
///
|
||||||
|
/// Affiche un message de bienvenue avec un gradient coloré et une icône.
|
||||||
|
/// Conçu pour donner une impression chaleureuse et professionnelle à l'utilisateur.
|
||||||
|
class WelcomeSectionWidget extends StatelessWidget {
|
||||||
|
/// Titre principal affiché (par défaut "Bonjour !")
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Sous-titre descriptif (par défaut "Voici un aperçu de votre association")
|
||||||
|
final String subtitle;
|
||||||
|
|
||||||
|
/// Icône affichée à droite (par défaut Icons.dashboard)
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Couleurs du gradient (par défaut primaryColor vers primaryLight)
|
||||||
|
final List<Color>? gradientColors;
|
||||||
|
|
||||||
|
const WelcomeSectionWidget({
|
||||||
|
super.key,
|
||||||
|
this.title = 'Bonjour !',
|
||||||
|
this.subtitle = 'Voici un aperçu de votre association',
|
||||||
|
this.icon = Icons.dashboard,
|
||||||
|
this.gradientColors,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = gradientColors ?? [AppTheme.primaryColor, AppTheme.primaryLight];
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: colors,
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withOpacity(0.9),
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,9 @@ class MembreDetailLoaded extends MembresState {
|
|||||||
List<Object?> get props => [membre];
|
List<Object?> get props => [membre];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Alias pour MembreDetailLoaded pour compatibilité
|
||||||
|
typedef MembreLoaded = MembreDetailLoaded;
|
||||||
|
|
||||||
/// État de succès pour les statistiques
|
/// État de succès pour les statistiques
|
||||||
class MembresStatsLoaded extends MembresState {
|
class MembresStatsLoaded extends MembresState {
|
||||||
const MembresStatsLoaded(this.stats);
|
const MembresStatsLoaded(this.stats);
|
||||||
|
|||||||
@@ -0,0 +1,937 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../../core/di/injection.dart';
|
||||||
|
import '../../../../core/models/membre_model.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/widgets/custom_text_field.dart';
|
||||||
|
import '../../../../shared/widgets/buttons/buttons.dart';
|
||||||
|
import '../bloc/membres_bloc.dart';
|
||||||
|
import '../bloc/membres_event.dart';
|
||||||
|
import '../bloc/membres_state.dart';
|
||||||
|
|
||||||
|
|
||||||
|
/// Page de création d'un nouveau membre
|
||||||
|
class MembreCreatePage extends StatefulWidget {
|
||||||
|
const MembreCreatePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MembreCreatePage> createState() => _MembreCreatePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MembreCreatePageState extends State<MembreCreatePage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late MembresBloc _membresBloc;
|
||||||
|
late TabController _tabController;
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
// Controllers pour les champs du formulaire
|
||||||
|
final _nomController = TextEditingController();
|
||||||
|
final _prenomController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _telephoneController = TextEditingController();
|
||||||
|
final _adresseController = TextEditingController();
|
||||||
|
final _villeController = TextEditingController();
|
||||||
|
final _codePostalController = TextEditingController();
|
||||||
|
final _paysController = TextEditingController();
|
||||||
|
final _professionController = TextEditingController();
|
||||||
|
final _numeroMembreController = TextEditingController();
|
||||||
|
|
||||||
|
// Variables d'état
|
||||||
|
DateTime? _dateNaissance;
|
||||||
|
DateTime _dateAdhesion = DateTime.now();
|
||||||
|
bool _actif = true;
|
||||||
|
bool _isLoading = false;
|
||||||
|
int _currentStep = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_membresBloc = getIt<MembresBloc>();
|
||||||
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
|
|
||||||
|
// Générer un numéro de membre automatique
|
||||||
|
_generateMemberNumber();
|
||||||
|
|
||||||
|
// Initialiser les valeurs par défaut
|
||||||
|
_paysController.text = 'Côte d\'Ivoire';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
_nomController.dispose();
|
||||||
|
_prenomController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_telephoneController.dispose();
|
||||||
|
_adresseController.dispose();
|
||||||
|
_villeController.dispose();
|
||||||
|
_codePostalController.dispose();
|
||||||
|
_paysController.dispose();
|
||||||
|
_professionController.dispose();
|
||||||
|
_numeroMembreController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _generateMemberNumber() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final year = now.year.toString().substring(2);
|
||||||
|
final month = now.month.toString().padLeft(2, '0');
|
||||||
|
final random = (DateTime.now().millisecondsSinceEpoch % 1000).toString().padLeft(3, '0');
|
||||||
|
_numeroMembreController.text = 'MBR$year$month$random';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider.value(
|
||||||
|
value: _membresBloc,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: AppTheme.backgroundLight,
|
||||||
|
appBar: _buildAppBar(),
|
||||||
|
body: BlocConsumer<MembresBloc, MembresState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is MembreCreated) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Membre créé avec succès !'),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.of(context).pop(true); // Retourner true pour indiquer le succès
|
||||||
|
} else if (state is MembresError) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(state.message),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildProgressIndicator(),
|
||||||
|
Expanded(
|
||||||
|
child: _buildFormContent(),
|
||||||
|
),
|
||||||
|
_buildBottomActions(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferredSizeWidget _buildAppBar() {
|
||||||
|
return AppBar(
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
title: const Text(
|
||||||
|
'Nouveau membre',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.help_outline),
|
||||||
|
onPressed: _showHelp,
|
||||||
|
tooltip: 'Aide',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProgressIndicator() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
color: Colors.white,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildStepIndicator(0, 'Informations\npersonnelles', Icons.person),
|
||||||
|
_buildStepConnector(0),
|
||||||
|
_buildStepIndicator(1, 'Contact &\nAdresse', Icons.contact_mail),
|
||||||
|
_buildStepConnector(1),
|
||||||
|
_buildStepIndicator(2, 'Finalisation', Icons.check_circle),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: (_currentStep + 1) / 3,
|
||||||
|
backgroundColor: AppTheme.backgroundLight,
|
||||||
|
valueColor: const AlwaysStoppedAnimation<Color>(AppTheme.primaryColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepIndicator(int step, String label, IconData icon) {
|
||||||
|
final isActive = step == _currentStep;
|
||||||
|
final isCompleted = step < _currentStep;
|
||||||
|
|
||||||
|
Color color;
|
||||||
|
if (isCompleted) {
|
||||||
|
color = AppTheme.successColor;
|
||||||
|
} else if (isActive) {
|
||||||
|
color = AppTheme.primaryColor;
|
||||||
|
} else {
|
||||||
|
color = AppTheme.textHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCompleted ? AppTheme.successColor :
|
||||||
|
isActive ? AppTheme.primaryColor : AppTheme.backgroundLight,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: color, width: 2),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isCompleted ? Icons.check : icon,
|
||||||
|
color: isCompleted || isActive ? Colors.white : color,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: color,
|
||||||
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepConnector(int step) {
|
||||||
|
final isCompleted = step < _currentStep;
|
||||||
|
return Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 2,
|
||||||
|
margin: const EdgeInsets.only(bottom: 32),
|
||||||
|
color: isCompleted ? AppTheme.successColor : AppTheme.backgroundLight,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFormContent() {
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: PageView(
|
||||||
|
controller: PageController(initialPage: _currentStep),
|
||||||
|
onPageChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
_currentStep = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
_buildPersonalInfoStep(),
|
||||||
|
_buildContactStep(),
|
||||||
|
_buildFinalizationStep(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPersonalInfoStep() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Informations personnelles',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Renseignez les informations de base du nouveau membre',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Numéro de membre (généré automatiquement)
|
||||||
|
CustomTextField(
|
||||||
|
controller: _numeroMembreController,
|
||||||
|
label: 'Numéro de membre',
|
||||||
|
prefixIcon: Icons.badge,
|
||||||
|
enabled: false,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Le numéro de membre est requis';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Nom et Prénom
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: CustomTextField(
|
||||||
|
controller: _prenomController,
|
||||||
|
label: 'Prénom *',
|
||||||
|
hintText: 'Jean',
|
||||||
|
prefixIcon: Icons.person_outline,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Le prénom est requis';
|
||||||
|
}
|
||||||
|
if (value.trim().length < 2) {
|
||||||
|
return 'Le prénom doit contenir au moins 2 caractères';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: CustomTextField(
|
||||||
|
controller: _nomController,
|
||||||
|
label: 'Nom *',
|
||||||
|
hintText: 'Dupont',
|
||||||
|
prefixIcon: Icons.person_outline,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Le nom est requis';
|
||||||
|
}
|
||||||
|
if (value.trim().length < 2) {
|
||||||
|
return 'Le nom doit contenir au moins 2 caractères';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Date de naissance
|
||||||
|
InkWell(
|
||||||
|
onTap: _selectDateNaissance,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: AppTheme.borderColor),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.cake_outlined, color: AppTheme.textSecondary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Date de naissance',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_dateNaissance != null
|
||||||
|
? DateFormat('dd/MM/yyyy').format(_dateNaissance!)
|
||||||
|
: 'Sélectionner une date',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: _dateNaissance != null
|
||||||
|
? AppTheme.textPrimary
|
||||||
|
: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.calendar_today, color: AppTheme.textSecondary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Profession
|
||||||
|
CustomTextField(
|
||||||
|
controller: _professionController,
|
||||||
|
label: 'Profession',
|
||||||
|
hintText: 'Enseignant, Commerçant, etc.',
|
||||||
|
prefixIcon: Icons.work_outline,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContactStep() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Contact & Adresse',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Informations de contact et adresse du membre',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Email
|
||||||
|
CustomTextField(
|
||||||
|
controller: _emailController,
|
||||||
|
label: 'Email *',
|
||||||
|
hintText: 'exemple@email.com',
|
||||||
|
prefixIcon: Icons.email_outlined,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'L\'email est requis';
|
||||||
|
}
|
||||||
|
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
||||||
|
return 'Format d\'email invalide';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Téléphone
|
||||||
|
CustomTextField(
|
||||||
|
controller: _telephoneController,
|
||||||
|
label: 'Téléphone *',
|
||||||
|
hintText: '+225 XX XX XX XX XX',
|
||||||
|
prefixIcon: Icons.phone_outlined,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9+\-\s\(\)]')),
|
||||||
|
],
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Le téléphone est requis';
|
||||||
|
}
|
||||||
|
if (value.trim().length < 8) {
|
||||||
|
return 'Numéro de téléphone invalide';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Section Adresse
|
||||||
|
const Text(
|
||||||
|
'Adresse',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Adresse
|
||||||
|
CustomTextField(
|
||||||
|
controller: _adresseController,
|
||||||
|
label: 'Adresse',
|
||||||
|
hintText: 'Rue, quartier, etc.',
|
||||||
|
prefixIcon: Icons.location_on_outlined,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Ville et Code postal
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: CustomTextField(
|
||||||
|
controller: _villeController,
|
||||||
|
label: 'Ville',
|
||||||
|
hintText: 'Abidjan',
|
||||||
|
prefixIcon: Icons.location_city_outlined,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: CustomTextField(
|
||||||
|
controller: _codePostalController,
|
||||||
|
label: 'Code postal',
|
||||||
|
hintText: '00225',
|
||||||
|
prefixIcon: Icons.markunread_mailbox_outlined,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Pays
|
||||||
|
CustomTextField(
|
||||||
|
controller: _paysController,
|
||||||
|
label: 'Pays',
|
||||||
|
prefixIcon: Icons.flag_outlined,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFinalizationStep() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Finalisation',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Vérifiez les informations et finalisez la création',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Résumé des informations
|
||||||
|
_buildSummaryCard(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Date d'adhésion
|
||||||
|
InkWell(
|
||||||
|
onTap: _selectDateAdhesion,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: AppTheme.borderColor),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.calendar_today_outlined, color: AppTheme.textSecondary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Date d\'adhésion',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
DateFormat('dd/MM/yyyy').format(_dateAdhesion),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.edit, color: AppTheme.textSecondary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Statut actif
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Membre actif'),
|
||||||
|
subtitle: const Text('Le membre peut accéder aux services'),
|
||||||
|
value: _actif,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_actif = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
activeColor: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryCard() {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.summarize, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Résumé des informations',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildSummaryRow('Nom complet', '${_prenomController.text} ${_nomController.text}'),
|
||||||
|
_buildSummaryRow('Email', _emailController.text),
|
||||||
|
_buildSummaryRow('Téléphone', _telephoneController.text),
|
||||||
|
if (_dateNaissance != null)
|
||||||
|
_buildSummaryRow('Date de naissance', DateFormat('dd/MM/yyyy').format(_dateNaissance!)),
|
||||||
|
if (_professionController.text.isNotEmpty)
|
||||||
|
_buildSummaryRow('Profession', _professionController.text),
|
||||||
|
if (_adresseController.text.isNotEmpty)
|
||||||
|
_buildSummaryRow('Adresse', _adresseController.text),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryRow(String label, String value) {
|
||||||
|
if (value.trim().isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomActions() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black12,
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (_currentStep > 0)
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _previousStep,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppTheme.primaryColor,
|
||||||
|
side: const BorderSide(color: AppTheme.primaryColor),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Précédent'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_currentStep > 0) const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
flex: _currentStep == 0 ? 1 : 1,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _handleNextOrSubmit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(_currentStep == 2 ? 'Créer le membre' : 'Suivant'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _previousStep() {
|
||||||
|
if (_currentStep > 0) {
|
||||||
|
setState(() {
|
||||||
|
_currentStep--;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleNextOrSubmit() {
|
||||||
|
if (_currentStep < 2) {
|
||||||
|
if (_validateCurrentStep()) {
|
||||||
|
setState(() {
|
||||||
|
_currentStep++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_submitForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _validateCurrentStep() {
|
||||||
|
switch (_currentStep) {
|
||||||
|
case 0:
|
||||||
|
return _validatePersonalInfo();
|
||||||
|
case 1:
|
||||||
|
return _validateContactInfo();
|
||||||
|
case 2:
|
||||||
|
return true; // Pas de validation spécifique pour la finalisation
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _validatePersonalInfo() {
|
||||||
|
bool isValid = true;
|
||||||
|
|
||||||
|
if (_prenomController.text.trim().isEmpty) {
|
||||||
|
_showFieldError('Le prénom est requis');
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_nomController.text.trim().isEmpty) {
|
||||||
|
_showFieldError('Le nom est requis');
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _validateContactInfo() {
|
||||||
|
bool isValid = true;
|
||||||
|
|
||||||
|
if (_emailController.text.trim().isEmpty) {
|
||||||
|
_showFieldError('L\'email est requis');
|
||||||
|
isValid = false;
|
||||||
|
} else if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(_emailController.text)) {
|
||||||
|
_showFieldError('Format d\'email invalide');
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_telephoneController.text.trim().isEmpty) {
|
||||||
|
_showFieldError('Le téléphone est requis');
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFieldError(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submitForm() {
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Créer le modèle membre
|
||||||
|
final membre = MembreModel(
|
||||||
|
id: '', // Sera généré par le backend
|
||||||
|
numeroMembre: _numeroMembreController.text.trim(),
|
||||||
|
nom: _nomController.text.trim(),
|
||||||
|
prenom: _prenomController.text.trim(),
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
telephone: _telephoneController.text.trim(),
|
||||||
|
dateNaissance: _dateNaissance,
|
||||||
|
adresse: _adresseController.text.trim().isNotEmpty ? _adresseController.text.trim() : null,
|
||||||
|
ville: _villeController.text.trim().isNotEmpty ? _villeController.text.trim() : null,
|
||||||
|
codePostal: _codePostalController.text.trim().isNotEmpty ? _codePostalController.text.trim() : null,
|
||||||
|
pays: _paysController.text.trim().isNotEmpty ? _paysController.text.trim() : null,
|
||||||
|
profession: _professionController.text.trim().isNotEmpty ? _professionController.text.trim() : null,
|
||||||
|
dateAdhesion: _dateAdhesion,
|
||||||
|
actif: _actif,
|
||||||
|
statut: 'ACTIF',
|
||||||
|
version: 1,
|
||||||
|
dateCreation: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Envoyer l'événement de création
|
||||||
|
_membresBloc.add(CreateMembre(membre));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectDateNaissance() async {
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _dateNaissance ?? DateTime.now().subtract(const Duration(days: 365 * 25)),
|
||||||
|
firstDate: DateTime(1900),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
locale: const Locale('fr', 'FR'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (date != null) {
|
||||||
|
setState(() {
|
||||||
|
_dateNaissance = date;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectDateAdhesion() async {
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _dateAdhesion,
|
||||||
|
firstDate: DateTime(2000),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
locale: const Locale('fr', 'FR'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (date != null) {
|
||||||
|
setState(() {
|
||||||
|
_dateAdhesion = date;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showHelp() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Aide - Création de membre'),
|
||||||
|
content: const SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Étapes de création :',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text('1. Informations personnelles : Nom, prénom, date de naissance'),
|
||||||
|
Text('2. Contact & Adresse : Email, téléphone, adresse'),
|
||||||
|
Text('3. Finalisation : Vérification et validation'),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Champs obligatoires :',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text('• Nom et prénom'),
|
||||||
|
Text('• Email (format valide)'),
|
||||||
|
Text('• Téléphone'),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Le numéro de membre est généré automatiquement selon le format : MBR + Année + Mois + Numéro séquentiel',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../../core/di/injection.dart';
|
||||||
|
import '../../../../core/models/membre_model.dart';
|
||||||
|
import '../../../../core/models/cotisation_model.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
|
||||||
|
import '../bloc/membres_bloc.dart';
|
||||||
|
import '../bloc/membres_event.dart';
|
||||||
|
import '../bloc/membres_state.dart';
|
||||||
|
import '../widgets/membre_info_section.dart';
|
||||||
|
import '../widgets/membre_stats_section.dart';
|
||||||
|
import '../widgets/membre_cotisations_section.dart';
|
||||||
|
import '../widgets/membre_actions_section.dart';
|
||||||
|
import '../widgets/membre_delete_dialog.dart';
|
||||||
|
import 'membre_edit_page.dart';
|
||||||
|
|
||||||
|
/// Page de détails complète d'un membre
|
||||||
|
class MembreDetailsPage extends StatefulWidget {
|
||||||
|
const MembreDetailsPage({
|
||||||
|
super.key,
|
||||||
|
required this.membreId,
|
||||||
|
this.membre,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String membreId;
|
||||||
|
final MembreModel? membre;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MembreDetailsPage> createState() => _MembreDetailsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MembreDetailsPageState extends State<MembreDetailsPage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late MembresBloc _membresBloc;
|
||||||
|
late TabController _tabController;
|
||||||
|
|
||||||
|
MembreModel? _currentMembre;
|
||||||
|
List<CotisationModel> _cotisations = [];
|
||||||
|
bool _isLoadingCotisations = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_membresBloc = getIt<MembresBloc>();
|
||||||
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
|
_currentMembre = widget.membre;
|
||||||
|
|
||||||
|
// Charger les détails du membre si pas fourni
|
||||||
|
if (_currentMembre == null) {
|
||||||
|
_membresBloc.add(LoadMembreById(widget.membreId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger les cotisations du membre
|
||||||
|
_loadMemberCotisations();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMemberCotisations() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingCotisations = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Implémenter le chargement des cotisations via le repository
|
||||||
|
// final cotisations = await getIt<CotisationRepository>()
|
||||||
|
// .getCotisationsByMembre(widget.membreId);
|
||||||
|
// setState(() {
|
||||||
|
// _cotisations = cotisations;
|
||||||
|
// });
|
||||||
|
|
||||||
|
// Simulation temporaire
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
setState(() {
|
||||||
|
_cotisations = _generateMockCotisations();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Gérer l'erreur
|
||||||
|
debugPrint('Erreur lors du chargement des cotisations: $e');
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingCotisations = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CotisationModel> _generateMockCotisations() {
|
||||||
|
// Données de test temporaires
|
||||||
|
return [
|
||||||
|
CotisationModel(
|
||||||
|
id: '1',
|
||||||
|
numeroReference: 'COT-2025-001',
|
||||||
|
membreId: widget.membreId,
|
||||||
|
typeCotisation: 'MENSUELLE',
|
||||||
|
periode: 'Janvier 2025',
|
||||||
|
montantDu: 25000,
|
||||||
|
montantPaye: 25000,
|
||||||
|
codeDevise: 'XOF',
|
||||||
|
statut: 'PAYEE',
|
||||||
|
dateEcheance: DateTime(2025, 1, 31),
|
||||||
|
datePaiement: DateTime(2025, 1, 15),
|
||||||
|
annee: 2025,
|
||||||
|
recurrente: true,
|
||||||
|
nombreRappels: 0,
|
||||||
|
dateCreation: DateTime(2025, 1, 1),
|
||||||
|
),
|
||||||
|
CotisationModel(
|
||||||
|
id: '2',
|
||||||
|
numeroReference: 'COT-2025-002',
|
||||||
|
membreId: widget.membreId,
|
||||||
|
typeCotisation: 'MENSUELLE',
|
||||||
|
periode: 'Février 2025',
|
||||||
|
montantDu: 25000,
|
||||||
|
montantPaye: 0,
|
||||||
|
codeDevise: 'XOF',
|
||||||
|
statut: 'EN_ATTENTE',
|
||||||
|
dateEcheance: DateTime(2025, 2, 28),
|
||||||
|
annee: 2025,
|
||||||
|
recurrente: true,
|
||||||
|
nombreRappels: 1,
|
||||||
|
dateCreation: DateTime(2025, 2, 1),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider.value(
|
||||||
|
value: _membresBloc,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: AppTheme.backgroundLight,
|
||||||
|
body: BlocConsumer<MembresBloc, MembresState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is MembreLoaded) {
|
||||||
|
setState(() {
|
||||||
|
_currentMembre = state.membre;
|
||||||
|
});
|
||||||
|
} else if (state is MembresError) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(state.message),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is MembresLoading && _currentMembre == null) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Chargement des détails...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is MembresError && _currentMembre == null) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error, size: 64, color: AppTheme.errorColor),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(state.message),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => _membresBloc.add(LoadMembreById(widget.membreId)),
|
||||||
|
child: Text('Réessayer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_currentMembre == null) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.person_off, size: 64),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Membre non trouvé'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildContent();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent() {
|
||||||
|
return NestedScrollView(
|
||||||
|
headerSliverBuilder: (context, innerBoxIsScrolled) {
|
||||||
|
return [
|
||||||
|
_buildAppBar(innerBoxIsScrolled),
|
||||||
|
_buildMemberHeader(),
|
||||||
|
_buildTabBar(),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
body: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
_buildInfoTab(),
|
||||||
|
_buildCotisationsTab(),
|
||||||
|
_buildStatsTab(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(bool innerBoxIsScrolled) {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 0,
|
||||||
|
floating: true,
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
title: Text(
|
||||||
|
_currentMembre?.nomComplet ?? 'Détails du membre',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
onPressed: _editMember,
|
||||||
|
tooltip: 'Modifier',
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: _handleMenuAction,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'call',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.phone),
|
||||||
|
title: Text('Appeler'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'message',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.message),
|
||||||
|
title: Text('Message'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'export',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.download),
|
||||||
|
title: Text('Exporter'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.delete, color: Colors.red),
|
||||||
|
title: Text('Supprimer', style: TextStyle(color: Colors.red)),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMemberHeader() {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Container(
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||||
|
child: MembreInfoSection(
|
||||||
|
membre: _currentMembre!,
|
||||||
|
showActions: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTabBar() {
|
||||||
|
return SliverPersistentHeader(
|
||||||
|
pinned: true,
|
||||||
|
delegate: _TabBarDelegate(
|
||||||
|
TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
labelColor: AppTheme.primaryColor,
|
||||||
|
unselectedLabelColor: AppTheme.textSecondary,
|
||||||
|
indicatorColor: AppTheme.primaryColor,
|
||||||
|
indicatorWeight: 3,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'Informations'),
|
||||||
|
Tab(text: 'Cotisations'),
|
||||||
|
Tab(text: 'Statistiques'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoTab() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
MembreInfoSection(
|
||||||
|
membre: _currentMembre!,
|
||||||
|
showActions: true,
|
||||||
|
onEdit: _editMember,
|
||||||
|
onCall: _callMember,
|
||||||
|
onMessage: _messageMember,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
MembreActionsSection(
|
||||||
|
membre: _currentMembre!,
|
||||||
|
onEdit: _editMember,
|
||||||
|
onDelete: _deleteMember,
|
||||||
|
onExport: _exportMember,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCotisationsTab() {
|
||||||
|
return MembreCotisationsSection(
|
||||||
|
membre: _currentMembre!,
|
||||||
|
cotisations: _cotisations,
|
||||||
|
isLoading: _isLoadingCotisations,
|
||||||
|
onRefresh: _loadMemberCotisations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatsTab() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: MembreStatsSection(
|
||||||
|
membre: _currentMembre!,
|
||||||
|
cotisations: _cotisations,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _editMember() async {
|
||||||
|
if (widget.membre == null) return;
|
||||||
|
|
||||||
|
final result = await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => MembreEditPage(membre: widget.membre!),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si le membre a été modifié avec succès, recharger les données
|
||||||
|
if (result == true) {
|
||||||
|
_loadMemberCotisations();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Membre modifié avec succès !'),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _callMember() {
|
||||||
|
// TODO: Implémenter l'appel
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Appel - À implémenter')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _messageMember() {
|
||||||
|
// TODO: Implémenter l'envoi de message
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Message - À implémenter')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteMember() async {
|
||||||
|
if (widget.membre == null) return;
|
||||||
|
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => MembreDeleteDialog(membre: widget.membre!),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si le membre a été supprimé/désactivé avec succès
|
||||||
|
if (result == true && mounted) {
|
||||||
|
// Retourner à la liste des membres
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Membre traité avec succès !'),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exportMember() {
|
||||||
|
// TODO: Implémenter l'export
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Export - À implémenter')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleMenuAction(String action) {
|
||||||
|
switch (action) {
|
||||||
|
case 'call':
|
||||||
|
_callMember();
|
||||||
|
break;
|
||||||
|
case 'message':
|
||||||
|
_messageMember();
|
||||||
|
break;
|
||||||
|
case 'export':
|
||||||
|
_exportMember();
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
_deleteMember();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TabBarDelegate extends SliverPersistentHeaderDelegate {
|
||||||
|
const _TabBarDelegate(this.tabBar);
|
||||||
|
|
||||||
|
final TabBar tabBar;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get minExtent => tabBar.preferredSize.height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get maxExtent => tabBar.preferredSize.height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.white,
|
||||||
|
child: tabBar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../../core/di/injection.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../bloc/membres_bloc.dart';
|
||||||
|
import '../bloc/membres_event.dart';
|
||||||
|
import '../bloc/membres_state.dart';
|
||||||
|
|
||||||
|
class MembresDashboardPage extends StatefulWidget {
|
||||||
|
const MembresDashboardPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MembresDashboardPage> createState() => _MembresDashboardPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MembresDashboardPageState extends State<MembresDashboardPage> {
|
||||||
|
late MembresBloc _membresBloc;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_membresBloc = getIt<MembresBloc>();
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadData() {
|
||||||
|
_membresBloc.add(const LoadMembres());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
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),
|
||||||
|
onPressed: _loadData,
|
||||||
|
tooltip: 'Actualiser',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Erreur de chargement',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
state.message,
|
||||||
|
style: 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDashboard() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.dashboard,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Dashboard Vide',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Prêt à être reconstruit pièce par pièce',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
import '../../../../core/di/injection.dart';
|
import '../../../../core/di/injection.dart';
|
||||||
|
import '../../../../core/models/membre_model.dart';
|
||||||
import '../../../../shared/theme/app_theme.dart';
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
import '../../../../shared/widgets/coming_soon_page.dart';
|
import '../../../../shared/widgets/coming_soon_page.dart';
|
||||||
import '../bloc/membres_bloc.dart';
|
import '../bloc/membres_bloc.dart';
|
||||||
@@ -9,6 +10,12 @@ import '../bloc/membres_event.dart';
|
|||||||
import '../bloc/membres_state.dart';
|
import '../bloc/membres_state.dart';
|
||||||
import '../widgets/membre_card.dart';
|
import '../widgets/membre_card.dart';
|
||||||
import '../widgets/membres_search_bar.dart';
|
import '../widgets/membres_search_bar.dart';
|
||||||
|
import '../widgets/membre_delete_dialog.dart';
|
||||||
|
import '../widgets/membres_advanced_search.dart';
|
||||||
|
import '../widgets/membres_export_dialog.dart';
|
||||||
|
import 'membre_details_page.dart';
|
||||||
|
import 'membre_create_page.dart';
|
||||||
|
import 'membres_dashboard_page.dart';
|
||||||
|
|
||||||
|
|
||||||
/// Page de liste des membres avec fonctionnalités avancées
|
/// Page de liste des membres avec fonctionnalités avancées
|
||||||
@@ -23,6 +30,7 @@ class _MembresListPageState extends State<MembresListPage> {
|
|||||||
final RefreshController _refreshController = RefreshController();
|
final RefreshController _refreshController = RefreshController();
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
late MembresBloc _membresBloc;
|
late MembresBloc _membresBloc;
|
||||||
|
List<MembreModel> _membres = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -56,6 +64,16 @@ class _MembresListPageState extends State<MembresListPage> {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
onPressed: () => _showAdvancedSearch(),
|
||||||
|
tooltip: 'Recherche avancée',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.file_download),
|
||||||
|
onPressed: () => _showExportDialog(),
|
||||||
|
tooltip: 'Exporter',
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add_circle_outline),
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
onPressed: () => _showAddMemberDialog(),
|
onPressed: () => _showAddMemberDialog(),
|
||||||
@@ -97,7 +115,14 @@ class _MembresListPageState extends State<MembresListPage> {
|
|||||||
} else if (state is MembresErrorWithData) {
|
} else if (state is MembresErrorWithData) {
|
||||||
_showErrorSnackBar(state.message);
|
_showErrorSnackBar(state.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mettre à jour la liste des membres
|
||||||
|
if (state is MembresLoaded) {
|
||||||
|
_membres = state.membres;
|
||||||
|
} else if (state is MembresErrorWithData) {
|
||||||
|
_membres = state.membres;
|
||||||
|
}
|
||||||
|
|
||||||
// Arrêter le refresh
|
// Arrêter le refresh
|
||||||
if (state is! MembresRefreshing && state is! MembresLoading) {
|
if (state is! MembresRefreshing && state is! MembresLoading) {
|
||||||
_refreshController.refreshCompleted();
|
_refreshController.refreshCompleted();
|
||||||
@@ -288,30 +313,28 @@ class _MembresListPageState extends State<MembresListPage> {
|
|||||||
|
|
||||||
/// Affiche les détails d'un membre
|
/// Affiche les détails d'un membre
|
||||||
void _showMemberDetails(membre) {
|
void _showMemberDetails(membre) {
|
||||||
// TODO: Implémenter la page de détails
|
Navigator.of(context).push(
|
||||||
showDialog(
|
MaterialPageRoute(
|
||||||
context: context,
|
builder: (context) => MembreDetailsPage(
|
||||||
builder: (context) => const ComingSoonPage(
|
membreId: membre.id,
|
||||||
title: 'Détails du membre',
|
membre: membre,
|
||||||
description: 'La page de détails du membre sera bientôt disponible.',
|
),
|
||||||
icon: Icons.person,
|
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Affiche le dialog d'ajout de membre
|
/// Affiche le formulaire d'ajout de membre
|
||||||
void _showAddMemberDialog() {
|
void _showAddMemberDialog() async {
|
||||||
// TODO: Implémenter le formulaire d'ajout
|
final result = await Navigator.of(context).push(
|
||||||
showDialog(
|
MaterialPageRoute(
|
||||||
context: context,
|
builder: (context) => const MembreCreatePage(),
|
||||||
builder: (context) => const ComingSoonPage(
|
|
||||||
title: 'Ajouter un membre',
|
|
||||||
description: 'Le formulaire d\'ajout de membre sera bientôt disponible.',
|
|
||||||
icon: Icons.person_add,
|
|
||||||
color: AppTheme.successColor,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Si un membre a été créé avec succès, recharger la liste
|
||||||
|
if (result == true) {
|
||||||
|
_membresBloc.add(const RefreshMembres());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Affiche le dialog d'édition de membre
|
/// Affiche le dialog d'édition de membre
|
||||||
@@ -329,29 +352,59 @@ class _MembresListPageState extends State<MembresListPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Affiche la confirmation de suppression
|
/// Affiche la confirmation de suppression
|
||||||
void _showDeleteConfirmation(membre) {
|
void _showDeleteConfirmation(membre) async {
|
||||||
// TODO: Implémenter la confirmation de suppression
|
final result = await showDialog<bool>(
|
||||||
showDialog(
|
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => const ComingSoonPage(
|
barrierDismissible: false,
|
||||||
title: 'Supprimer le membre',
|
builder: (context) => MembreDeleteDialog(membre: membre),
|
||||||
description: 'La confirmation de suppression sera bientôt disponible.',
|
|
||||||
icon: Icons.delete,
|
|
||||||
color: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Si le membre a été supprimé/désactivé avec succès, recharger la liste
|
||||||
|
if (result == true) {
|
||||||
|
_membresBloc.add(const RefreshMembres());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Affiche les statistiques
|
/// Affiche les statistiques
|
||||||
void _showStatsDialog() {
|
void _showStatsDialog() {
|
||||||
// TODO: Implémenter les statistiques
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const MembresDashboardPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affiche la recherche avancée
|
||||||
|
void _showAdvancedSearch() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.9,
|
||||||
|
minChildSize: 0.5,
|
||||||
|
maxChildSize: 0.95,
|
||||||
|
builder: (context, scrollController) => MembresAdvancedSearch(
|
||||||
|
onSearch: (filters) {
|
||||||
|
// TODO: Implémenter la recherche avec filtres
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Recherche avec ${filters.length} filtres - À implémenter'),
|
||||||
|
backgroundColor: AppTheme.infoColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affiche le dialog d'export
|
||||||
|
void _showExportDialog() {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => const ComingSoonPage(
|
builder: (context) => MembresExportDialog(
|
||||||
title: 'Statistiques',
|
membres: _membres,
|
||||||
description: 'Les statistiques des membres seront bientôt disponibles.',
|
|
||||||
icon: Icons.analytics,
|
|
||||||
color: AppTheme.infoColor,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/theme/design_system.dart';
|
||||||
|
|
||||||
|
/// Container professionnel pour les graphiques du dashboard avec animations
|
||||||
|
class DashboardChartCard extends StatefulWidget {
|
||||||
|
const DashboardChartCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
this.subtitle,
|
||||||
|
this.actions,
|
||||||
|
this.height,
|
||||||
|
this.isLoading = false,
|
||||||
|
this.onRefresh,
|
||||||
|
this.showBorder = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
final String? subtitle;
|
||||||
|
final List<Widget>? actions;
|
||||||
|
final double? height;
|
||||||
|
final bool isLoading;
|
||||||
|
final VoidCallback? onRefresh;
|
||||||
|
final bool showBorder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DashboardChartCard> createState() => _DashboardChartCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DashboardChartCardState extends State<DashboardChartCard>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _slideAnimation;
|
||||||
|
late Animation<double> _fadeAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: DesignSystem.animationMedium,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_slideAnimation = Tween<double>(
|
||||||
|
begin: 30.0,
|
||||||
|
end: 0.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurveEnter,
|
||||||
|
));
|
||||||
|
|
||||||
|
_fadeAnimation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurve,
|
||||||
|
));
|
||||||
|
|
||||||
|
_animationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(0, _slideAnimation.value),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: _fadeAnimation,
|
||||||
|
child: _buildCard(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCard() {
|
||||||
|
return Container(
|
||||||
|
height: widget.height,
|
||||||
|
padding: EdgeInsets.all(DesignSystem.spacingLg),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.surfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
|
||||||
|
boxShadow: DesignSystem.shadowCard,
|
||||||
|
border: widget.showBorder ? Border.all(
|
||||||
|
color: AppTheme.borderColor.withOpacity(0.5),
|
||||||
|
width: 1,
|
||||||
|
) : null,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
SizedBox(height: DesignSystem.spacingLg),
|
||||||
|
Expanded(
|
||||||
|
child: widget.isLoading ? _buildLoadingState() : widget.child,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.title,
|
||||||
|
style: DesignSystem.headlineMedium.copyWith(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.subtitle != null) ...[
|
||||||
|
SizedBox(height: DesignSystem.spacingXs),
|
||||||
|
Text(
|
||||||
|
widget.subtitle!,
|
||||||
|
style: DesignSystem.bodyMedium.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.actions != null || widget.onRefresh != null)
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (widget.onRefresh != null)
|
||||||
|
_buildRefreshButton(),
|
||||||
|
if (widget.actions != null) ...widget.actions!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRefreshButton() {
|
||||||
|
return Container(
|
||||||
|
margin: EdgeInsets.only(right: DesignSystem.spacingSm),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: widget.onRefresh,
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.all(DesignSystem.spacingSm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.refresh,
|
||||||
|
size: 18,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadingState() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 3,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.primaryColor.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: DesignSystem.spacingMd),
|
||||||
|
Text(
|
||||||
|
'Chargement des données...',
|
||||||
|
style: DesignSystem.bodyMedium.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/theme/design_system.dart';
|
||||||
|
|
||||||
|
/// Card statistique professionnelle avec design basé sur le nombre d'or
|
||||||
|
class DashboardStatCard extends StatefulWidget {
|
||||||
|
const DashboardStatCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
this.trend,
|
||||||
|
this.subtitle,
|
||||||
|
this.onTap,
|
||||||
|
this.isLoading = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final String? trend;
|
||||||
|
final String? subtitle;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DashboardStatCard> createState() => _DashboardStatCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DashboardStatCardState extends State<DashboardStatCard>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
late Animation<double> _fadeAnimation;
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: DesignSystem.animationMedium,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_scaleAnimation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurveEnter,
|
||||||
|
));
|
||||||
|
|
||||||
|
_fadeAnimation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurve,
|
||||||
|
));
|
||||||
|
|
||||||
|
_animationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.scale(
|
||||||
|
scale: _scaleAnimation.value,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: _fadeAnimation,
|
||||||
|
child: _buildCard(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCard(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => _setHovered(true),
|
||||||
|
onExit: (_) => _setHovered(false),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: widget.onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: DesignSystem.animationFast,
|
||||||
|
curve: DesignSystem.animationCurve,
|
||||||
|
padding: EdgeInsets.all(DesignSystem.spacingLg),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.surfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
|
||||||
|
boxShadow: _isHovered ? DesignSystem.shadowCardHover : DesignSystem.shadowCard,
|
||||||
|
border: Border.all(
|
||||||
|
color: widget.color.withOpacity(0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: widget.isLoading ? _buildLoadingState() : _buildContent(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadingState() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
_buildShimmer(40, 40, isCircular: true),
|
||||||
|
if (widget.trend != null) _buildShimmer(60, 24, radius: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: DesignSystem.spacingMd),
|
||||||
|
_buildShimmer(80, 32),
|
||||||
|
SizedBox(height: DesignSystem.spacingSm),
|
||||||
|
_buildShimmer(120, 16),
|
||||||
|
if (widget.subtitle != null) ...[
|
||||||
|
SizedBox(height: DesignSystem.spacingXs),
|
||||||
|
_buildShimmer(100, 14),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildShimmer(double width, double height, {double? radius, bool isCircular = false}) {
|
||||||
|
return Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.textHint.withOpacity(0.1),
|
||||||
|
borderRadius: isCircular
|
||||||
|
? BorderRadius.circular(height / 2)
|
||||||
|
: BorderRadius.circular(radius ?? DesignSystem.radiusSm),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
SizedBox(height: DesignSystem.goldenHeight(DesignSystem.spacingLg)),
|
||||||
|
_buildValue(),
|
||||||
|
SizedBox(height: DesignSystem.spacingSm),
|
||||||
|
_buildTitle(),
|
||||||
|
if (widget.subtitle != null) ...[
|
||||||
|
SizedBox(height: DesignSystem.spacingXs),
|
||||||
|
_buildSubtitle(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
_buildIconContainer(),
|
||||||
|
if (widget.trend != null) _buildTrendBadge(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIconContainer() {
|
||||||
|
return Container(
|
||||||
|
width: DesignSystem.goldenWidth(32),
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
widget.color.withOpacity(0.15),
|
||||||
|
widget.color.withOpacity(0.05),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
|
||||||
|
border: Border.all(
|
||||||
|
color: widget.color.withOpacity(0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
widget.icon,
|
||||||
|
color: widget.color,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrendBadge() {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: DesignSystem.spacingSm,
|
||||||
|
vertical: DesignSystem.spacingXs,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getTrendColor().withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusXl),
|
||||||
|
border: Border.all(
|
||||||
|
color: _getTrendColor().withOpacity(0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getTrendIcon(),
|
||||||
|
color: _getTrendColor(),
|
||||||
|
size: 14,
|
||||||
|
),
|
||||||
|
SizedBox(width: DesignSystem.spacing2xs),
|
||||||
|
Text(
|
||||||
|
widget.trend!,
|
||||||
|
style: DesignSystem.labelSmall.copyWith(
|
||||||
|
color: _getTrendColor(),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildValue() {
|
||||||
|
return Text(
|
||||||
|
widget.value,
|
||||||
|
style: DesignSystem.displayMedium.copyWith(
|
||||||
|
color: widget.color,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 28,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTitle() {
|
||||||
|
return Text(
|
||||||
|
widget.title,
|
||||||
|
style: DesignSystem.labelLarge.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubtitle() {
|
||||||
|
return Text(
|
||||||
|
widget.subtitle!,
|
||||||
|
style: DesignSystem.labelMedium.copyWith(
|
||||||
|
color: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setHovered(bool hovered) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isHovered = hovered;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getTrendColor() {
|
||||||
|
if (widget.trend == null) return AppTheme.textSecondary;
|
||||||
|
|
||||||
|
if (widget.trend!.startsWith('+')) {
|
||||||
|
return AppTheme.successColor;
|
||||||
|
} else if (widget.trend!.startsWith('-')) {
|
||||||
|
return AppTheme.errorColor;
|
||||||
|
} else {
|
||||||
|
return AppTheme.warningColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getTrendIcon() {
|
||||||
|
if (widget.trend == null) return Icons.trending_flat;
|
||||||
|
|
||||||
|
if (widget.trend!.startsWith('+')) {
|
||||||
|
return Icons.trending_up;
|
||||||
|
} else if (widget.trend!.startsWith('-')) {
|
||||||
|
return Icons.trending_down;
|
||||||
|
} else {
|
||||||
|
return Icons.trending_flat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../../../../core/models/membre_model.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../pages/membre_edit_page.dart';
|
||||||
|
|
||||||
|
/// Section des actions disponibles pour un membre
|
||||||
|
class MembreActionsSection extends StatelessWidget {
|
||||||
|
const MembreActionsSection({
|
||||||
|
super.key,
|
||||||
|
required this.membre,
|
||||||
|
this.onEdit,
|
||||||
|
this.onDelete,
|
||||||
|
this.onExport,
|
||||||
|
this.onCall,
|
||||||
|
this.onMessage,
|
||||||
|
this.onEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MembreModel membre;
|
||||||
|
final VoidCallback? onEdit;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
final VoidCallback? onExport;
|
||||||
|
final VoidCallback? onCall;
|
||||||
|
final VoidCallback? onMessage;
|
||||||
|
final VoidCallback? onEmail;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.settings,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Actions',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildActionGrid(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionGrid(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildActionButton(
|
||||||
|
context,
|
||||||
|
'Modifier',
|
||||||
|
Icons.edit,
|
||||||
|
AppTheme.primaryColor,
|
||||||
|
onEdit ?? () => _showNotImplemented(context, 'Modification'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildActionButton(
|
||||||
|
context,
|
||||||
|
'Appeler',
|
||||||
|
Icons.phone,
|
||||||
|
AppTheme.successColor,
|
||||||
|
onCall ?? () => _callMember(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildActionButton(
|
||||||
|
context,
|
||||||
|
'Message',
|
||||||
|
Icons.message,
|
||||||
|
AppTheme.infoColor,
|
||||||
|
onMessage ?? () => _messageMember(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildActionButton(
|
||||||
|
context,
|
||||||
|
'Email',
|
||||||
|
Icons.email,
|
||||||
|
AppTheme.warningColor,
|
||||||
|
onEmail ?? () => _emailMember(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildActionButton(
|
||||||
|
context,
|
||||||
|
'Exporter',
|
||||||
|
Icons.download,
|
||||||
|
AppTheme.textSecondary,
|
||||||
|
onExport ?? () => _exportMember(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildActionButton(
|
||||||
|
context,
|
||||||
|
'Supprimer',
|
||||||
|
Icons.delete,
|
||||||
|
AppTheme.errorColor,
|
||||||
|
onDelete ?? () => _deleteMember(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildQuickInfoSection(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionButton(
|
||||||
|
BuildContext context,
|
||||||
|
String label,
|
||||||
|
IconData icon,
|
||||||
|
Color color,
|
||||||
|
VoidCallback onPressed,
|
||||||
|
) {
|
||||||
|
return Material(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: color.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 24),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuickInfoSection(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.backgroundLight,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Informations rapides',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildQuickInfoRow(
|
||||||
|
'Numéro de membre',
|
||||||
|
membre.numeroMembre,
|
||||||
|
Icons.badge,
|
||||||
|
() => _copyToClipboard(context, membre.numeroMembre, 'Numéro de membre'),
|
||||||
|
),
|
||||||
|
_buildQuickInfoRow(
|
||||||
|
'Téléphone',
|
||||||
|
membre.telephone,
|
||||||
|
Icons.phone,
|
||||||
|
() => _copyToClipboard(context, membre.telephone, 'Téléphone'),
|
||||||
|
),
|
||||||
|
_buildQuickInfoRow(
|
||||||
|
'Email',
|
||||||
|
membre.email,
|
||||||
|
Icons.email,
|
||||||
|
() => _copyToClipboard(context, membre.email, 'Email'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuickInfoRow(
|
||||||
|
String label,
|
||||||
|
String value,
|
||||||
|
IconData icon,
|
||||||
|
VoidCallback onTap,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: AppTheme.textSecondary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.copy,
|
||||||
|
size: 14,
|
||||||
|
color: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _callMember(BuildContext context) {
|
||||||
|
// TODO: Implémenter l'appel téléphonique
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Appeler le membre'),
|
||||||
|
content: Text('Voulez-vous appeler ${membre.prenom} ${membre.nom} au ${membre.telephone} ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_showNotImplemented(context, 'Appel téléphonique');
|
||||||
|
},
|
||||||
|
child: const Text('Appeler'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _messageMember(BuildContext context) {
|
||||||
|
// TODO: Implémenter l'envoi de SMS
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Envoyer un message'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Envoyer un SMS à ${membre.prenom} ${membre.nom} ?'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Message',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_showNotImplemented(context, 'Envoi de SMS');
|
||||||
|
},
|
||||||
|
child: const Text('Envoyer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emailMember(BuildContext context) {
|
||||||
|
// TODO: Implémenter l'envoi d'email
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Envoyer un email'),
|
||||||
|
content: Text('Ouvrir l\'application email pour envoyer un message à ${membre.email} ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_showNotImplemented(context, 'Envoi d\'email');
|
||||||
|
},
|
||||||
|
child: const Text('Ouvrir'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exportMember(BuildContext context) {
|
||||||
|
// TODO: Implémenter l'export des données du membre
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Exporter les données'),
|
||||||
|
content: Text('Exporter les données de ${membre.prenom} ${membre.nom} ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_showNotImplemented(context, 'Export des données');
|
||||||
|
},
|
||||||
|
child: const Text('Exporter'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteMember(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Supprimer le membre'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.warning,
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Êtes-vous sûr de vouloir supprimer ${membre.prenom} ${membre.nom} ?',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Cette action est irréversible.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_showNotImplemented(context, 'Suppression du membre');
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copyToClipboard(BuildContext context, String text, String label) {
|
||||||
|
Clipboard.setData(ClipboardData(text: text));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('$label copié dans le presse-papiers'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showNotImplemented(BuildContext context, String feature) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('$feature - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: AppTheme.infoColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../../core/models/membre_model.dart';
|
||||||
|
import '../../../../core/models/cotisation_model.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
|
||||||
|
|
||||||
|
/// Section des cotisations d'un membre
|
||||||
|
class MembreCotisationsSection extends StatelessWidget {
|
||||||
|
const MembreCotisationsSection({
|
||||||
|
super.key,
|
||||||
|
required this.membre,
|
||||||
|
required this.cotisations,
|
||||||
|
required this.isLoading,
|
||||||
|
this.onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MembreModel membre;
|
||||||
|
final List<CotisationModel> cotisations;
|
||||||
|
final bool isLoading;
|
||||||
|
final VoidCallback? onRefresh;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Chargement des cotisations...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
onRefresh?.call();
|
||||||
|
},
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildSummaryCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildCotisationsList(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryCard() {
|
||||||
|
final totalDu = cotisations.fold<double>(
|
||||||
|
0,
|
||||||
|
(sum, cotisation) => sum + cotisation.montantDu,
|
||||||
|
);
|
||||||
|
|
||||||
|
final totalPaye = cotisations.fold<double>(
|
||||||
|
0,
|
||||||
|
(sum, cotisation) => sum + cotisation.montantPaye,
|
||||||
|
);
|
||||||
|
|
||||||
|
final totalRestant = totalDu - totalPaye;
|
||||||
|
|
||||||
|
final cotisationsPayees = cotisations.where((c) => c.statut == 'PAYEE').length;
|
||||||
|
final cotisationsEnRetard = cotisations.where((c) => c.isEnRetard).length;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.account_balance_wallet,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Résumé des cotisations',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildSummaryItem(
|
||||||
|
'Total dû',
|
||||||
|
_formatAmount(totalDu),
|
||||||
|
AppTheme.infoColor,
|
||||||
|
Icons.receipt_long,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildSummaryItem(
|
||||||
|
'Payé',
|
||||||
|
_formatAmount(totalPaye),
|
||||||
|
AppTheme.successColor,
|
||||||
|
Icons.check_circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildSummaryItem(
|
||||||
|
'Restant',
|
||||||
|
_formatAmount(totalRestant),
|
||||||
|
totalRestant > 0 ? AppTheme.warningColor : AppTheme.successColor,
|
||||||
|
Icons.pending,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildSummaryItem(
|
||||||
|
'En retard',
|
||||||
|
'$cotisationsEnRetard',
|
||||||
|
cotisationsEnRetard > 0 ? AppTheme.errorColor : AppTheme.successColor,
|
||||||
|
Icons.warning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: totalDu > 0 ? totalPaye / totalDu : 0,
|
||||||
|
backgroundColor: AppTheme.backgroundLight,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
totalPaye == totalDu ? AppTheme.successColor : AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'$cotisationsPayees/${cotisations.length} cotisations payées',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryItem(String label, String value, Color color, IconData icon) {
|
||||||
|
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(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 20),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCotisationsList() {
|
||||||
|
if (cotisations.isEmpty) {
|
||||||
|
return Card(
|
||||||
|
elevation: 1,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.receipt_long_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Aucune cotisation',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'Ce membre n\'a pas encore de cotisations enregistrées.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.list_alt,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Historique des cotisations',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...cotisations.map((cotisation) => _buildCotisationCard(cotisation)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCotisationCard(CotisationModel cotisation) {
|
||||||
|
return Card(
|
||||||
|
elevation: 1,
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
cotisation.periode ?? 'Période non définie',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
cotisation.typeCotisation,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildStatusBadge(cotisation),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildCotisationDetail(
|
||||||
|
'Montant dû',
|
||||||
|
_formatAmount(cotisation.montantDu),
|
||||||
|
Icons.receipt,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildCotisationDetail(
|
||||||
|
'Montant payé',
|
||||||
|
_formatAmount(cotisation.montantPaye),
|
||||||
|
Icons.payment,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildCotisationDetail(
|
||||||
|
'Échéance',
|
||||||
|
DateFormat('dd/MM/yyyy').format(cotisation.dateEcheance),
|
||||||
|
Icons.schedule,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (cotisation.datePaiement != null)
|
||||||
|
Expanded(
|
||||||
|
child: _buildCotisationDetail(
|
||||||
|
'Payé le',
|
||||||
|
DateFormat('dd/MM/yyyy').format(cotisation.datePaiement!),
|
||||||
|
Icons.check_circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusBadge(CotisationModel cotisation) {
|
||||||
|
Color color;
|
||||||
|
String label;
|
||||||
|
|
||||||
|
switch (cotisation.statut) {
|
||||||
|
case 'PAYEE':
|
||||||
|
color = AppTheme.successColor;
|
||||||
|
label = 'Payée';
|
||||||
|
break;
|
||||||
|
case 'EN_ATTENTE':
|
||||||
|
color = AppTheme.warningColor;
|
||||||
|
label = 'En attente';
|
||||||
|
break;
|
||||||
|
case 'EN_RETARD':
|
||||||
|
color = AppTheme.errorColor;
|
||||||
|
label = 'En retard';
|
||||||
|
break;
|
||||||
|
case 'PARTIELLEMENT_PAYEE':
|
||||||
|
color = AppTheme.infoColor;
|
||||||
|
label = 'Partielle';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
color = AppTheme.textSecondary;
|
||||||
|
label = cotisation.statut;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: color.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCotisationDetail(String label, String value, IconData icon) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: AppTheme.textSecondary),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatAmount(double amount) {
|
||||||
|
return NumberFormat.currency(
|
||||||
|
locale: 'fr_FR',
|
||||||
|
symbol: 'FCFA',
|
||||||
|
decimalDigits: 0,
|
||||||
|
).format(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../../core/di/injection.dart';
|
||||||
|
import '../../../../core/models/membre_model.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../bloc/membres_bloc.dart';
|
||||||
|
import '../bloc/membres_event.dart';
|
||||||
|
import '../bloc/membres_state.dart';
|
||||||
|
|
||||||
|
/// Dialog de confirmation de suppression/désactivation d'un membre
|
||||||
|
class MembreDeleteDialog extends StatefulWidget {
|
||||||
|
const MembreDeleteDialog({
|
||||||
|
super.key,
|
||||||
|
required this.membre,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MembreModel membre;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MembreDeleteDialog> createState() => _MembreDeleteDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MembreDeleteDialogState extends State<MembreDeleteDialog> {
|
||||||
|
late MembresBloc _membresBloc;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _softDelete = true; // Par défaut, désactivation plutôt que suppression
|
||||||
|
bool _hasActiveCotisations = false;
|
||||||
|
bool _hasUnpaidCotisations = false;
|
||||||
|
int _totalCotisations = 0;
|
||||||
|
double _unpaidAmount = 0.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_membresBloc = getIt<MembresBloc>();
|
||||||
|
_checkMemberDependencies();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkMemberDependencies() {
|
||||||
|
// TODO: Implémenter la vérification des dépendances via le repository
|
||||||
|
// Pour l'instant, simulation avec des données fictives
|
||||||
|
setState(() {
|
||||||
|
_hasActiveCotisations = true;
|
||||||
|
_hasUnpaidCotisations = true;
|
||||||
|
_totalCotisations = 5;
|
||||||
|
_unpaidAmount = 75000.0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider.value(
|
||||||
|
value: _membresBloc,
|
||||||
|
child: BlocConsumer<MembresBloc, MembresState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state is MembreDeleted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} else if (state is MembreUpdated) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} else if (state is MembresError) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(state.message),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_softDelete ? Icons.person_off : Icons.delete_forever,
|
||||||
|
color: _softDelete ? AppTheme.warningColor : AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_softDelete ? 'Désactiver le membre' : 'Supprimer le membre',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Informations du membre
|
||||||
|
_buildMemberInfo(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Vérifications des dépendances
|
||||||
|
if (_hasActiveCotisations || _hasUnpaidCotisations)
|
||||||
|
_buildDependenciesWarning(),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Options de suppression
|
||||||
|
_buildDeleteOptions(),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Message de confirmation
|
||||||
|
_buildConfirmationMessage(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isLoading ? null : () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _handleDelete,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _softDelete ? AppTheme.warningColor : AppTheme.errorColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(_softDelete ? 'Désactiver' : 'Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMemberInfo() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.backgroundLight,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppTheme.borderColor),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
child: Text(
|
||||||
|
'${widget.membre.prenom[0]}${widget.membre.nom[0]}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${widget.membre.prenom} ${widget.membre.nom}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.membre.numeroMembre,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.membre.actif ? AppTheme.successColor : AppTheme.errorColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
widget.membre.actif ? 'Actif' : 'Inactif',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
widget.membre.email,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDependenciesWarning() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.warningColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppTheme.warningColor.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.warning_amber,
|
||||||
|
color: AppTheme.warningColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Attention - Dépendances détectées',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.warningColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_hasActiveCotisations) ...[
|
||||||
|
Text(
|
||||||
|
'• $_totalCotisations cotisations associées à ce membre',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_hasUnpaidCotisations) ...[
|
||||||
|
Text(
|
||||||
|
'• ${_unpaidAmount.toStringAsFixed(0)} XOF de cotisations impayées',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'La désactivation est recommandée pour préserver l\'historique.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDeleteOptions() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Options de suppression :',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Option désactivation
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_softDelete = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _softDelete ? AppTheme.warningColor.withOpacity(0.1) : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: _softDelete ? AppTheme.warningColor : AppTheme.borderColor,
|
||||||
|
width: _softDelete ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Radio<bool>(
|
||||||
|
value: true,
|
||||||
|
groupValue: _softDelete,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_softDelete = value!;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
activeColor: AppTheme.warningColor,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Désactiver le membre (Recommandé)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'Le membre sera marqué comme inactif mais ses données et historique seront préservés.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Option suppression définitive
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_softDelete = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: !_softDelete ? AppTheme.errorColor.withOpacity(0.1) : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: !_softDelete ? AppTheme.errorColor : AppTheme.borderColor,
|
||||||
|
width: !_softDelete ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Radio<bool>(
|
||||||
|
value: false,
|
||||||
|
groupValue: _softDelete,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_softDelete = value!;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
activeColor: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Supprimer définitivement',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'ATTENTION : Cette action est irréversible. Toutes les données du membre seront perdues.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildConfirmationMessage() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _softDelete
|
||||||
|
? AppTheme.warningColor.withOpacity(0.1)
|
||||||
|
: AppTheme.errorColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: _softDelete
|
||||||
|
? AppTheme.warningColor.withOpacity(0.3)
|
||||||
|
: AppTheme.errorColor.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_softDelete
|
||||||
|
? 'Le membre "${widget.membre.prenom} ${widget.membre.nom}" sera désactivé et ne pourra plus accéder aux services, mais son historique sera préservé.'
|
||||||
|
: 'Le membre "${widget.membre.prenom} ${widget.membre.nom}" sera définitivement supprimé avec toutes ses données. Cette action ne peut pas être annulée.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _softDelete ? AppTheme.warningColor : AppTheme.errorColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDelete() {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_softDelete) {
|
||||||
|
// Désactivation du membre
|
||||||
|
final membreDesactive = widget.membre.copyWith(
|
||||||
|
actif: false,
|
||||||
|
version: widget.membre.version + 1,
|
||||||
|
dateModification: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final memberId = widget.membre.id;
|
||||||
|
if (memberId != null && memberId.isNotEmpty) {
|
||||||
|
_membresBloc.add(UpdateMembre(memberId, membreDesactive));
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Erreur : ID du membre manquant'),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Suppression définitive
|
||||||
|
final memberId = widget.membre.id;
|
||||||
|
if (memberId != null && memberId.isNotEmpty) {
|
||||||
|
_membresBloc.add(DeleteMembre(memberId));
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Erreur : ID du membre manquant'),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../../core/models/membre_model.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Section d'informations détaillées d'un membre
|
||||||
|
class MembreInfoSection extends StatelessWidget {
|
||||||
|
const MembreInfoSection({
|
||||||
|
super.key,
|
||||||
|
required this.membre,
|
||||||
|
this.showActions = false,
|
||||||
|
this.onEdit,
|
||||||
|
this.onCall,
|
||||||
|
this.onMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MembreModel membre;
|
||||||
|
final bool showActions;
|
||||||
|
final VoidCallback? onEdit;
|
||||||
|
final VoidCallback? onCall;
|
||||||
|
final VoidCallback? onMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildPersonalInfo(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildContactInfo(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildMembershipInfo(),
|
||||||
|
if (showActions) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildActionButtons(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
_buildAvatar(),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
membre.nomComplet,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
membre.numeroMembre,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildStatusBadge(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAvatar() {
|
||||||
|
return Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(40),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
size: 40,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusBadge() {
|
||||||
|
final isActive = membre.actif;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive ? AppTheme.successColor : AppTheme.errorColor,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isActive ? 'Actif' : 'Inactif',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPersonalInfo() {
|
||||||
|
return _buildSection(
|
||||||
|
title: 'Informations personnelles',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
children: [
|
||||||
|
_buildInfoRow(
|
||||||
|
icon: Icons.cake_outlined,
|
||||||
|
label: 'Date de naissance',
|
||||||
|
value: membre.dateNaissance != null
|
||||||
|
? DateFormat('dd/MM/yyyy').format(membre.dateNaissance!)
|
||||||
|
: 'Non renseignée',
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
icon: Icons.work_outline,
|
||||||
|
label: 'Profession',
|
||||||
|
value: membre.profession ?? 'Non renseignée',
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
icon: Icons.location_on_outlined,
|
||||||
|
label: 'Adresse',
|
||||||
|
value: _buildFullAddress(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContactInfo() {
|
||||||
|
return _buildSection(
|
||||||
|
title: 'Contact',
|
||||||
|
icon: Icons.contact_phone_outlined,
|
||||||
|
children: [
|
||||||
|
_buildInfoRow(
|
||||||
|
icon: Icons.email_outlined,
|
||||||
|
label: 'Email',
|
||||||
|
value: membre.email,
|
||||||
|
isSelectable: true,
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
icon: Icons.phone_outlined,
|
||||||
|
label: 'Téléphone',
|
||||||
|
value: membre.telephone,
|
||||||
|
isSelectable: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMembershipInfo() {
|
||||||
|
return _buildSection(
|
||||||
|
title: 'Adhésion',
|
||||||
|
icon: Icons.card_membership_outlined,
|
||||||
|
children: [
|
||||||
|
_buildInfoRow(
|
||||||
|
icon: Icons.calendar_today_outlined,
|
||||||
|
label: 'Date d\'adhésion',
|
||||||
|
value: DateFormat('dd/MM/yyyy').format(membre.dateAdhesion),
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
icon: Icons.access_time_outlined,
|
||||||
|
label: 'Membre depuis',
|
||||||
|
value: _calculateMembershipDuration(),
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
icon: Icons.update_outlined,
|
||||||
|
label: 'Dernière modification',
|
||||||
|
value: membre.dateModification != null
|
||||||
|
? DateFormat('dd/MM/yyyy à HH:mm').format(membre.dateModification!)
|
||||||
|
: 'Jamais modifié',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSection({
|
||||||
|
required String title,
|
||||||
|
required IconData icon,
|
||||||
|
required List<Widget> children,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 20,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
bool isSelectable = false,
|
||||||
|
}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 16,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
isSelectable
|
||||||
|
? SelectableText(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionButtons() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: onEdit,
|
||||||
|
icon: const Icon(Icons.edit, size: 18),
|
||||||
|
label: const Text('Modifier'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: onCall,
|
||||||
|
icon: const Icon(Icons.phone, size: 18),
|
||||||
|
label: const Text('Appeler'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppTheme.primaryColor,
|
||||||
|
side: const BorderSide(color: AppTheme.primaryColor),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: onMessage,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppTheme.infoColor,
|
||||||
|
side: const BorderSide(color: AppTheme.infoColor),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.message, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildFullAddress() {
|
||||||
|
final parts = <String>[];
|
||||||
|
|
||||||
|
if (membre.adresse != null && membre.adresse!.isNotEmpty) {
|
||||||
|
parts.add(membre.adresse!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membre.ville != null && membre.ville!.isNotEmpty) {
|
||||||
|
parts.add(membre.ville!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membre.codePostal != null && membre.codePostal!.isNotEmpty) {
|
||||||
|
parts.add(membre.codePostal!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membre.pays != null && membre.pays!.isNotEmpty) {
|
||||||
|
parts.add(membre.pays!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.isNotEmpty ? parts.join(', ') : 'Non renseignée';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _calculateMembershipDuration() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final adhesion = membre.dateAdhesion;
|
||||||
|
|
||||||
|
final difference = now.difference(adhesion);
|
||||||
|
final years = (difference.inDays / 365).floor();
|
||||||
|
final months = ((difference.inDays % 365) / 30).floor();
|
||||||
|
|
||||||
|
if (years > 0) {
|
||||||
|
return months > 0 ? '$years an${years > 1 ? 's' : ''} et $months mois' : '$years an${years > 1 ? 's' : ''}';
|
||||||
|
} else if (months > 0) {
|
||||||
|
return '$months mois';
|
||||||
|
} else {
|
||||||
|
return '${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../../core/models/membre_model.dart';
|
||||||
|
import '../../../../core/models/cotisation_model.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// Section des statistiques d'un membre
|
||||||
|
class MembreStatsSection extends StatelessWidget {
|
||||||
|
const MembreStatsSection({
|
||||||
|
super.key,
|
||||||
|
required this.membre,
|
||||||
|
required this.cotisations,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MembreModel membre;
|
||||||
|
final List<CotisationModel> cotisations;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildOverviewCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPaymentChart(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildStatusChart(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildTimelineCard(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOverviewCard() {
|
||||||
|
final totalCotisations = cotisations.length;
|
||||||
|
final cotisationsPayees = cotisations.where((c) => c.statut == 'PAYEE').length;
|
||||||
|
final cotisationsEnRetard = cotisations.where((c) => c.isEnRetard).length;
|
||||||
|
final tauxPaiement = totalCotisations > 0 ? (cotisationsPayees / totalCotisations * 100) : 0.0;
|
||||||
|
|
||||||
|
final totalMontantDu = cotisations.fold<double>(0, (sum, c) => sum + c.montantDu);
|
||||||
|
final totalMontantPaye = cotisations.fold<double>(0, (sum, c) => sum + c.montantPaye);
|
||||||
|
|
||||||
|
final membershipDuration = DateTime.now().difference(membre.dateAdhesion).inDays;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.analytics,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Vue d\'ensemble',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
'Cotisations',
|
||||||
|
'$totalCotisations',
|
||||||
|
AppTheme.primaryColor,
|
||||||
|
Icons.receipt_long,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
'Taux de paiement',
|
||||||
|
'${tauxPaiement.toStringAsFixed(1)}%',
|
||||||
|
tauxPaiement >= 80 ? AppTheme.successColor :
|
||||||
|
tauxPaiement >= 50 ? AppTheme.warningColor : AppTheme.errorColor,
|
||||||
|
Icons.trending_up,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
'En retard',
|
||||||
|
'$cotisationsEnRetard',
|
||||||
|
cotisationsEnRetard > 0 ? AppTheme.errorColor : AppTheme.successColor,
|
||||||
|
Icons.warning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
'Ancienneté',
|
||||||
|
'${(membershipDuration / 365).floor()} an${(membershipDuration / 365).floor() > 1 ? 's' : ''}',
|
||||||
|
AppTheme.infoColor,
|
||||||
|
Icons.schedule,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.backgroundLight,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Total payé',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_formatAmount(totalMontantPaye),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Restant à payer',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_formatAmount(totalMontantDu - totalMontantPaye),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: totalMontantDu > totalMontantPaye ? AppTheme.warningColor : AppTheme.successColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatItem(String label, String value, Color color, IconData icon) {
|
||||||
|
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(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 20),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPaymentChart() {
|
||||||
|
if (cotisations.isEmpty) {
|
||||||
|
return _buildEmptyChart('Aucune donnée de paiement');
|
||||||
|
}
|
||||||
|
|
||||||
|
final paymentData = _getPaymentChartData();
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.pie_chart,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Répartition des paiements',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: PieChart(
|
||||||
|
PieChartData(
|
||||||
|
sections: paymentData,
|
||||||
|
centerSpaceRadius: 40,
|
||||||
|
sectionsSpace: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildChartLegend(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusChart() {
|
||||||
|
if (cotisations.isEmpty) {
|
||||||
|
return _buildEmptyChart('Aucune donnée de statut');
|
||||||
|
}
|
||||||
|
|
||||||
|
final statusData = _getStatusChartData();
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.bar_chart,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Évolution des montants',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: BarChart(
|
||||||
|
BarChartData(
|
||||||
|
barGroups: statusData,
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 60,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
return Text(
|
||||||
|
_formatAmount(value),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
final index = value.toInt();
|
||||||
|
if (index >= 0 && index < cotisations.length) {
|
||||||
|
return Text(
|
||||||
|
(cotisations[index].periode ?? 'N/A').substring(0, 3),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Text('');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
gridData: const FlGridData(show: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTimelineCard() {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.timeline,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Chronologie',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildTimelineItem(
|
||||||
|
'Adhésion',
|
||||||
|
DateFormat('dd/MM/yyyy').format(membre.dateAdhesion),
|
||||||
|
AppTheme.primaryColor,
|
||||||
|
Icons.person_add,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
if (cotisations.isNotEmpty) ...[
|
||||||
|
_buildTimelineItem(
|
||||||
|
'Première cotisation',
|
||||||
|
DateFormat('dd/MM/yyyy').format(
|
||||||
|
cotisations.map((c) => c.dateCreation).reduce((a, b) => a.isBefore(b) ? a : b),
|
||||||
|
),
|
||||||
|
AppTheme.infoColor,
|
||||||
|
Icons.payment,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
_buildTimelineItem(
|
||||||
|
'Dernière cotisation',
|
||||||
|
DateFormat('dd/MM/yyyy').format(
|
||||||
|
cotisations.map((c) => c.dateCreation).reduce((a, b) => a.isAfter(b) ? a : b),
|
||||||
|
),
|
||||||
|
AppTheme.successColor,
|
||||||
|
Icons.receipt,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTimelineItem(String title, String date, Color color, IconData icon, bool showLine) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: Colors.white, size: 16),
|
||||||
|
),
|
||||||
|
if (showLine)
|
||||||
|
Container(
|
||||||
|
width: 2,
|
||||||
|
height: 24,
|
||||||
|
color: color.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
date,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyChart(String message) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(40),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.bar_chart,
|
||||||
|
size: 48,
|
||||||
|
color: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChartLegend() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildLegendItem('Payé', AppTheme.successColor),
|
||||||
|
_buildLegendItem('En attente', AppTheme.warningColor),
|
||||||
|
_buildLegendItem('En retard', AppTheme.errorColor),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLegendItem(String label, Color color) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PieChartSectionData> _getPaymentChartData() {
|
||||||
|
final payees = cotisations.where((c) => c.statut == 'PAYEE').length;
|
||||||
|
final enAttente = cotisations.where((c) => c.statut == 'EN_ATTENTE').length;
|
||||||
|
final enRetard = cotisations.where((c) => c.isEnRetard).length;
|
||||||
|
final total = cotisations.length;
|
||||||
|
|
||||||
|
return [
|
||||||
|
if (payees > 0)
|
||||||
|
PieChartSectionData(
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
value: payees.toDouble(),
|
||||||
|
title: '${(payees / total * 100).toStringAsFixed(1)}%',
|
||||||
|
radius: 50,
|
||||||
|
),
|
||||||
|
if (enAttente > 0)
|
||||||
|
PieChartSectionData(
|
||||||
|
color: AppTheme.warningColor,
|
||||||
|
value: enAttente.toDouble(),
|
||||||
|
title: '${(enAttente / total * 100).toStringAsFixed(1)}%',
|
||||||
|
radius: 50,
|
||||||
|
),
|
||||||
|
if (enRetard > 0)
|
||||||
|
PieChartSectionData(
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
value: enRetard.toDouble(),
|
||||||
|
title: '${(enRetard / total * 100).toStringAsFixed(1)}%',
|
||||||
|
radius: 50,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BarChartGroupData> _getStatusChartData() {
|
||||||
|
return cotisations.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final cotisation = entry.value;
|
||||||
|
|
||||||
|
return BarChartGroupData(
|
||||||
|
x: index,
|
||||||
|
barRods: [
|
||||||
|
BarChartRodData(
|
||||||
|
toY: cotisation.montantDu,
|
||||||
|
color: AppTheme.infoColor.withOpacity(0.7),
|
||||||
|
width: 8,
|
||||||
|
),
|
||||||
|
BarChartRodData(
|
||||||
|
toY: cotisation.montantPaye,
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
width: 8,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatAmount(double amount) {
|
||||||
|
return NumberFormat.currency(
|
||||||
|
locale: 'fr_FR',
|
||||||
|
symbol: 'FCFA',
|
||||||
|
decimalDigits: 0,
|
||||||
|
).format(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/widgets/custom_text_field.dart';
|
||||||
|
|
||||||
|
/// Widget de recherche avancée pour les membres
|
||||||
|
class MembresAdvancedSearch extends StatefulWidget {
|
||||||
|
const MembresAdvancedSearch({
|
||||||
|
super.key,
|
||||||
|
required this.onSearch,
|
||||||
|
this.initialFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Function(Map<String, dynamic>) onSearch;
|
||||||
|
final Map<String, dynamic>? initialFilters;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MembresAdvancedSearch> createState() => _MembresAdvancedSearchState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MembresAdvancedSearchState extends State<MembresAdvancedSearch> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
// Contrôleurs de texte
|
||||||
|
final _nomController = TextEditingController();
|
||||||
|
final _prenomController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _telephoneController = TextEditingController();
|
||||||
|
final _numeroMembreController = TextEditingController();
|
||||||
|
final _professionController = TextEditingController();
|
||||||
|
final _villeController = TextEditingController();
|
||||||
|
|
||||||
|
// Filtres de statut
|
||||||
|
bool? _actifFilter;
|
||||||
|
|
||||||
|
// Filtres de date
|
||||||
|
DateTime? _dateAdhesionDebut;
|
||||||
|
DateTime? _dateAdhesionFin;
|
||||||
|
DateTime? _dateNaissanceDebut;
|
||||||
|
DateTime? _dateNaissanceFin;
|
||||||
|
|
||||||
|
// Filtres d'âge
|
||||||
|
int? _ageMin;
|
||||||
|
int? _ageMax;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeFilters() {
|
||||||
|
if (widget.initialFilters != null) {
|
||||||
|
final filters = widget.initialFilters!;
|
||||||
|
_nomController.text = filters['nom'] ?? '';
|
||||||
|
_prenomController.text = filters['prenom'] ?? '';
|
||||||
|
_emailController.text = filters['email'] ?? '';
|
||||||
|
_telephoneController.text = filters['telephone'] ?? '';
|
||||||
|
_numeroMembreController.text = filters['numeroMembre'] ?? '';
|
||||||
|
_professionController.text = filters['profession'] ?? '';
|
||||||
|
_villeController.text = filters['ville'] ?? '';
|
||||||
|
_actifFilter = filters['actif'];
|
||||||
|
_dateAdhesionDebut = filters['dateAdhesionDebut'];
|
||||||
|
_dateAdhesionFin = filters['dateAdhesionFin'];
|
||||||
|
_dateNaissanceDebut = filters['dateNaissanceDebut'];
|
||||||
|
_dateNaissanceFin = filters['dateNaissanceFin'];
|
||||||
|
_ageMin = filters['ageMin'];
|
||||||
|
_ageMax = filters['ageMax'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nomController.dispose();
|
||||||
|
_prenomController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_telephoneController.dispose();
|
||||||
|
_numeroMembreController.dispose();
|
||||||
|
_professionController.dispose();
|
||||||
|
_villeController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// En-tête
|
||||||
|
_buildHeader(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Contenu scrollable
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Informations personnelles
|
||||||
|
_buildSection(
|
||||||
|
'Informations personnelles',
|
||||||
|
Icons.person,
|
||||||
|
[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: CustomTextField(
|
||||||
|
controller: _nomController,
|
||||||
|
label: 'Nom',
|
||||||
|
prefixIcon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: CustomTextField(
|
||||||
|
controller: _prenomController,
|
||||||
|
label: 'Prénom',
|
||||||
|
prefixIcon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
CustomTextField(
|
||||||
|
controller: _numeroMembreController,
|
||||||
|
label: 'Numéro de membre',
|
||||||
|
prefixIcon: Icons.badge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
CustomTextField(
|
||||||
|
controller: _professionController,
|
||||||
|
label: 'Profession',
|
||||||
|
prefixIcon: Icons.work,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Contact et localisation
|
||||||
|
_buildSection(
|
||||||
|
'Contact et localisation',
|
||||||
|
Icons.contact_phone,
|
||||||
|
[
|
||||||
|
CustomTextField(
|
||||||
|
controller: _emailController,
|
||||||
|
label: 'Email',
|
||||||
|
prefixIcon: Icons.email,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
CustomTextField(
|
||||||
|
controller: _telephoneController,
|
||||||
|
label: 'Téléphone',
|
||||||
|
prefixIcon: Icons.phone,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
CustomTextField(
|
||||||
|
controller: _villeController,
|
||||||
|
label: 'Ville',
|
||||||
|
prefixIcon: Icons.location_city,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Statut et dates
|
||||||
|
_buildSection(
|
||||||
|
'Statut et dates',
|
||||||
|
Icons.calendar_today,
|
||||||
|
[
|
||||||
|
_buildStatusFilter(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildDateRangeFilter(
|
||||||
|
'Période d\'adhésion',
|
||||||
|
_dateAdhesionDebut,
|
||||||
|
_dateAdhesionFin,
|
||||||
|
(debut, fin) {
|
||||||
|
setState(() {
|
||||||
|
_dateAdhesionDebut = debut;
|
||||||
|
_dateAdhesionFin = fin;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildDateRangeFilter(
|
||||||
|
'Période de naissance',
|
||||||
|
_dateNaissanceDebut,
|
||||||
|
_dateNaissanceFin,
|
||||||
|
(debut, fin) {
|
||||||
|
setState(() {
|
||||||
|
_dateNaissanceDebut = debut;
|
||||||
|
_dateNaissanceFin = fin;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildAgeRangeFilter(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Boutons d'action
|
||||||
|
_buildActionButtons(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.search,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Recherche avancée',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSection(String title, IconData icon, List<Widget> children) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusFilter() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Statut du membre',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: RadioListTile<bool?>(
|
||||||
|
title: const Text('Tous', style: TextStyle(fontSize: 14)),
|
||||||
|
value: null,
|
||||||
|
groupValue: _actifFilter,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_actifFilter = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RadioListTile<bool?>(
|
||||||
|
title: const Text('Actifs', style: TextStyle(fontSize: 14)),
|
||||||
|
value: true,
|
||||||
|
groupValue: _actifFilter,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_actifFilter = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RadioListTile<bool?>(
|
||||||
|
title: const Text('Inactifs', style: TextStyle(fontSize: 14)),
|
||||||
|
value: false,
|
||||||
|
groupValue: _actifFilter,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_actifFilter = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDateRangeFilter(
|
||||||
|
String title,
|
||||||
|
DateTime? dateDebut,
|
||||||
|
DateTime? dateFin,
|
||||||
|
Function(DateTime?, DateTime?) onChanged,
|
||||||
|
) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _selectDate(context, dateDebut, (date) {
|
||||||
|
onChanged(date, dateFin);
|
||||||
|
}),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: AppTheme.borderColor),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.calendar_today,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
dateDebut != null
|
||||||
|
? DateFormat('dd/MM/yyyy').format(dateDebut)
|
||||||
|
: 'Date début',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: dateDebut != null
|
||||||
|
? AppTheme.textPrimary
|
||||||
|
: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _selectDate(context, dateFin, (date) {
|
||||||
|
onChanged(dateDebut, date);
|
||||||
|
}),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: AppTheme.borderColor),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.calendar_today,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
dateFin != null
|
||||||
|
? DateFormat('dd/MM/yyyy').format(dateFin)
|
||||||
|
: 'Date fin',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: dateFin != null
|
||||||
|
? AppTheme.textPrimary
|
||||||
|
: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAgeRangeFilter() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Tranche d\'âge',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: _ageMin?.toString(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Âge minimum',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onChanged: (value) {
|
||||||
|
_ageMin = int.tryParse(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: _ageMax?.toString(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Âge maximum',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onChanged: (value) {
|
||||||
|
_ageMax = int.tryParse(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionButtons() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _clearFilters,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
side: BorderSide(color: AppTheme.borderColor),
|
||||||
|
),
|
||||||
|
child: const Text('Effacer'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _performSearch,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
child: const Text('Rechercher'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectDate(
|
||||||
|
BuildContext context,
|
||||||
|
DateTime? initialDate,
|
||||||
|
Function(DateTime?) onDateSelected,
|
||||||
|
) async {
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: initialDate ?? DateTime.now(),
|
||||||
|
firstDate: DateTime(1900),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (date != null) {
|
||||||
|
onDateSelected(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearFilters() {
|
||||||
|
setState(() {
|
||||||
|
_nomController.clear();
|
||||||
|
_prenomController.clear();
|
||||||
|
_emailController.clear();
|
||||||
|
_telephoneController.clear();
|
||||||
|
_numeroMembreController.clear();
|
||||||
|
_professionController.clear();
|
||||||
|
_villeController.clear();
|
||||||
|
_actifFilter = null;
|
||||||
|
_dateAdhesionDebut = null;
|
||||||
|
_dateAdhesionFin = null;
|
||||||
|
_dateNaissanceDebut = null;
|
||||||
|
_dateNaissanceFin = null;
|
||||||
|
_ageMin = null;
|
||||||
|
_ageMax = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _performSearch() {
|
||||||
|
final filters = <String, dynamic>{};
|
||||||
|
|
||||||
|
// Ajout des filtres texte
|
||||||
|
if (_nomController.text.isNotEmpty) {
|
||||||
|
filters['nom'] = _nomController.text;
|
||||||
|
}
|
||||||
|
if (_prenomController.text.isNotEmpty) {
|
||||||
|
filters['prenom'] = _prenomController.text;
|
||||||
|
}
|
||||||
|
if (_emailController.text.isNotEmpty) {
|
||||||
|
filters['email'] = _emailController.text;
|
||||||
|
}
|
||||||
|
if (_telephoneController.text.isNotEmpty) {
|
||||||
|
filters['telephone'] = _telephoneController.text;
|
||||||
|
}
|
||||||
|
if (_numeroMembreController.text.isNotEmpty) {
|
||||||
|
filters['numeroMembre'] = _numeroMembreController.text;
|
||||||
|
}
|
||||||
|
if (_professionController.text.isNotEmpty) {
|
||||||
|
filters['profession'] = _professionController.text;
|
||||||
|
}
|
||||||
|
if (_villeController.text.isNotEmpty) {
|
||||||
|
filters['ville'] = _villeController.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajout des filtres de statut
|
||||||
|
if (_actifFilter != null) {
|
||||||
|
filters['actif'] = _actifFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajout des filtres de date
|
||||||
|
if (_dateAdhesionDebut != null) {
|
||||||
|
filters['dateAdhesionDebut'] = _dateAdhesionDebut;
|
||||||
|
}
|
||||||
|
if (_dateAdhesionFin != null) {
|
||||||
|
filters['dateAdhesionFin'] = _dateAdhesionFin;
|
||||||
|
}
|
||||||
|
if (_dateNaissanceDebut != null) {
|
||||||
|
filters['dateNaissanceDebut'] = _dateNaissanceDebut;
|
||||||
|
}
|
||||||
|
if (_dateNaissanceFin != null) {
|
||||||
|
filters['dateNaissanceFin'] = _dateNaissanceFin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajout des filtres d'âge
|
||||||
|
if (_ageMin != null) {
|
||||||
|
filters['ageMin'] = _ageMin;
|
||||||
|
}
|
||||||
|
if (_ageMax != null) {
|
||||||
|
filters['ageMax'] = _ageMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onSearch(filters);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../core/models/membre_model.dart';
|
||||||
|
|
||||||
|
/// Dialog d'export des données des membres
|
||||||
|
class MembresExportDialog extends StatefulWidget {
|
||||||
|
const MembresExportDialog({
|
||||||
|
super.key,
|
||||||
|
required this.membres,
|
||||||
|
this.selectedMembers,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<MembreModel> membres;
|
||||||
|
final List<MembreModel>? selectedMembers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MembresExportDialog> createState() => _MembresExportDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MembresExportDialogState extends State<MembresExportDialog> {
|
||||||
|
String _selectedFormat = 'excel';
|
||||||
|
bool _includeInactiveMembers = true;
|
||||||
|
bool _includePersonalInfo = true;
|
||||||
|
bool _includeContactInfo = true;
|
||||||
|
bool _includeAdhesionInfo = true;
|
||||||
|
bool _includeStatistics = false;
|
||||||
|
|
||||||
|
final List<String> _availableFormats = [
|
||||||
|
'excel',
|
||||||
|
'csv',
|
||||||
|
'pdf',
|
||||||
|
'json',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final membersToExport = widget.selectedMembers ?? widget.membres;
|
||||||
|
final activeMembers = membersToExport.where((m) => m.actif).length;
|
||||||
|
final inactiveMembers = membersToExport.length - activeMembers;
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.file_download,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Exporter les données',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Résumé des données à exporter
|
||||||
|
_buildDataSummary(membersToExport.length, activeMembers, inactiveMembers),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Sélection du format
|
||||||
|
_buildFormatSelection(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Options d'export
|
||||||
|
_buildExportOptions(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => _performExport(membersToExport),
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: const Text('Exporter'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDataSummary(int total, int active, int inactive) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.backgroundLight,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppTheme.borderColor),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Données à exporter',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildSummaryItem(
|
||||||
|
'Total',
|
||||||
|
total.toString(),
|
||||||
|
AppTheme.primaryColor,
|
||||||
|
Icons.people,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildSummaryItem(
|
||||||
|
'Actifs',
|
||||||
|
active.toString(),
|
||||||
|
AppTheme.successColor,
|
||||||
|
Icons.person,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildSummaryItem(
|
||||||
|
'Inactifs',
|
||||||
|
inactive.toString(),
|
||||||
|
AppTheme.errorColor,
|
||||||
|
Icons.person_off,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryItem(String label, String value, Color color, IconData icon) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
color: color,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFormatSelection() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Format d\'export',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _availableFormats.map((format) {
|
||||||
|
final isSelected = _selectedFormat == format;
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedFormat = format;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? AppTheme.primaryColor : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? AppTheme.primaryColor : AppTheme.borderColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getFormatIcon(format),
|
||||||
|
color: isSelected ? Colors.white : AppTheme.textSecondary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_getFormatLabel(format),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : AppTheme.textPrimary,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildExportOptions() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Options d\'export',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Inclusion des membres inactifs
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Inclure les membres inactifs'),
|
||||||
|
subtitle: const Text('Exporter aussi les membres désactivés'),
|
||||||
|
value: _includeInactiveMembers,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_includeInactiveMembers = value ?? true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(),
|
||||||
|
|
||||||
|
// Sections de données à inclure
|
||||||
|
const Text(
|
||||||
|
'Sections à inclure',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Informations personnelles'),
|
||||||
|
subtitle: const Text('Nom, prénom, date de naissance, etc.'),
|
||||||
|
value: _includePersonalInfo,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_includePersonalInfo = value ?? true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Informations de contact'),
|
||||||
|
subtitle: const Text('Email, téléphone, adresse'),
|
||||||
|
value: _includeContactInfo,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_includeContactInfo = value ?? true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Informations d\'adhésion'),
|
||||||
|
subtitle: const Text('Date d\'adhésion, statut, numéro de membre'),
|
||||||
|
value: _includeAdhesionInfo,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_includeAdhesionInfo = value ?? true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Statistiques'),
|
||||||
|
subtitle: const Text('Données de cotisations et statistiques'),
|
||||||
|
value: _includeStatistics,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_includeStatistics = value ?? false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getFormatIcon(String format) {
|
||||||
|
switch (format) {
|
||||||
|
case 'excel':
|
||||||
|
return Icons.table_chart;
|
||||||
|
case 'csv':
|
||||||
|
return Icons.text_snippet;
|
||||||
|
case 'pdf':
|
||||||
|
return Icons.picture_as_pdf;
|
||||||
|
case 'json':
|
||||||
|
return Icons.code;
|
||||||
|
default:
|
||||||
|
return Icons.file_download;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getFormatLabel(String format) {
|
||||||
|
switch (format) {
|
||||||
|
case 'excel':
|
||||||
|
return 'Excel (.xlsx)';
|
||||||
|
case 'csv':
|
||||||
|
return 'CSV (.csv)';
|
||||||
|
case 'pdf':
|
||||||
|
return 'PDF (.pdf)';
|
||||||
|
case 'json':
|
||||||
|
return 'JSON (.json)';
|
||||||
|
default:
|
||||||
|
return format.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _performExport(List<MembreModel> membersToExport) {
|
||||||
|
// Filtrer les membres selon les options
|
||||||
|
List<MembreModel> filteredMembers = membersToExport;
|
||||||
|
|
||||||
|
if (!_includeInactiveMembers) {
|
||||||
|
filteredMembers = filteredMembers.where((m) => m.actif).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer les options d'export
|
||||||
|
final exportOptions = {
|
||||||
|
'format': _selectedFormat,
|
||||||
|
'includePersonalInfo': _includePersonalInfo,
|
||||||
|
'includeContactInfo': _includeContactInfo,
|
||||||
|
'includeAdhesionInfo': _includeAdhesionInfo,
|
||||||
|
'includeStatistics': _includeStatistics,
|
||||||
|
'includeInactiveMembers': _includeInactiveMembers,
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Implémenter l'export réel selon le format
|
||||||
|
_showExportResult(filteredMembers.length, _selectedFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showExportResult(int count, String format) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Export $format de $count membres - À implémenter',
|
||||||
|
),
|
||||||
|
backgroundColor: AppTheme.infoColor,
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'Voir',
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Ouvrir le fichier exporté
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/theme/design_system.dart';
|
||||||
|
|
||||||
|
/// Floating Action Button moderne avec animations et design professionnel
|
||||||
|
class ModernFloatingActionButton extends StatefulWidget {
|
||||||
|
const ModernFloatingActionButton({
|
||||||
|
super.key,
|
||||||
|
required this.onPressed,
|
||||||
|
required this.icon,
|
||||||
|
this.label,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.foregroundColor,
|
||||||
|
this.heroTag,
|
||||||
|
this.tooltip,
|
||||||
|
this.mini = false,
|
||||||
|
this.extended = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final IconData icon;
|
||||||
|
final String? label;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final Color? foregroundColor;
|
||||||
|
final Object? heroTag;
|
||||||
|
final String? tooltip;
|
||||||
|
final bool mini;
|
||||||
|
final bool extended;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ModernFloatingActionButton> createState() => _ModernFloatingActionButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModernFloatingActionButtonState extends State<ModernFloatingActionButton>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
late Animation<double> _rotationAnimation;
|
||||||
|
bool _isPressed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: DesignSystem.animationFast,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_scaleAnimation = Tween<double>(
|
||||||
|
begin: 1.0,
|
||||||
|
end: 0.95,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurve,
|
||||||
|
));
|
||||||
|
|
||||||
|
_rotationAnimation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 0.1,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurve,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapDown(TapDownDetails details) {
|
||||||
|
setState(() => _isPressed = true);
|
||||||
|
_animationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapUp(TapUpDetails details) {
|
||||||
|
setState(() => _isPressed = false);
|
||||||
|
_animationController.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapCancel() {
|
||||||
|
setState(() => _isPressed = false);
|
||||||
|
_animationController.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.extended && widget.label != null) {
|
||||||
|
return _buildExtendedFAB();
|
||||||
|
}
|
||||||
|
return _buildRegularFAB();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRegularFAB() {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.scale(
|
||||||
|
scale: _scaleAnimation.value,
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: _rotationAnimation.value,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTapDown: _handleTapDown,
|
||||||
|
onTapUp: _handleTapUp,
|
||||||
|
onTapCancel: _handleTapCancel,
|
||||||
|
onTap: widget.onPressed,
|
||||||
|
child: Container(
|
||||||
|
width: widget.mini ? 40 : 56,
|
||||||
|
height: widget.mini ? 40 : 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: DesignSystem.primaryGradient,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
widget.mini ? 20 : 28,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: (widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.3),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
...DesignSystem.shadowCard,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
widget.mini ? 20 : 28,
|
||||||
|
),
|
||||||
|
onTap: widget.onPressed,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
widget.icon,
|
||||||
|
color: widget.foregroundColor ?? Colors.white,
|
||||||
|
size: widget.mini ? 20 : 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildExtendedFAB() {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.scale(
|
||||||
|
scale: _scaleAnimation.value,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTapDown: _handleTapDown,
|
||||||
|
onTapUp: _handleTapUp,
|
||||||
|
onTapCancel: _handleTapCancel,
|
||||||
|
onTap: widget.onPressed,
|
||||||
|
child: Container(
|
||||||
|
height: 48,
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: DesignSystem.spacingLg,
|
||||||
|
vertical: DesignSystem.spacingSm,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: DesignSystem.primaryGradient,
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusXl),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: (widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.3),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
...DesignSystem.shadowCard,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusXl),
|
||||||
|
onTap: widget.onPressed,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
widget.icon,
|
||||||
|
color: widget.foregroundColor ?? Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
SizedBox(width: DesignSystem.spacingSm),
|
||||||
|
Text(
|
||||||
|
widget.label!,
|
||||||
|
style: DesignSystem.labelLarge.copyWith(
|
||||||
|
color: widget.foregroundColor ?? Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget de FAB avec menu contextuel
|
||||||
|
class ModernFABWithMenu extends StatefulWidget {
|
||||||
|
const ModernFABWithMenu({
|
||||||
|
super.key,
|
||||||
|
required this.mainAction,
|
||||||
|
required this.menuItems,
|
||||||
|
this.heroTag,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ModernFABAction mainAction;
|
||||||
|
final List<ModernFABAction> menuItems;
|
||||||
|
final Object? heroTag;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ModernFABWithMenu> createState() => _ModernFABWithMenuState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModernFABWithMenuState extends State<ModernFABWithMenu>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _rotationAnimation;
|
||||||
|
bool _isOpen = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: DesignSystem.animationMedium,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_rotationAnimation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 0.75,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurve,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleMenu() {
|
||||||
|
setState(() {
|
||||||
|
_isOpen = !_isOpen;
|
||||||
|
if (_isOpen) {
|
||||||
|
_animationController.forward();
|
||||||
|
} else {
|
||||||
|
_animationController.reverse();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
|
children: [
|
||||||
|
// Menu items
|
||||||
|
...widget.menuItems.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final item = entry.value;
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
final offset = (index + 1) * 70.0 * _animationController.value;
|
||||||
|
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(0, -offset),
|
||||||
|
child: Opacity(
|
||||||
|
opacity: _animationController.value,
|
||||||
|
child: ModernFloatingActionButton(
|
||||||
|
onPressed: () {
|
||||||
|
_toggleMenu();
|
||||||
|
item.onPressed?.call();
|
||||||
|
},
|
||||||
|
icon: item.icon,
|
||||||
|
mini: true,
|
||||||
|
backgroundColor: item.backgroundColor,
|
||||||
|
foregroundColor: item.foregroundColor,
|
||||||
|
heroTag: '${widget.heroTag}_$index',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
|
||||||
|
// Main FAB
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _rotationAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.rotate(
|
||||||
|
angle: _rotationAnimation.value * 2 * 3.14159,
|
||||||
|
child: ModernFloatingActionButton(
|
||||||
|
onPressed: _toggleMenu,
|
||||||
|
icon: _isOpen ? Icons.close : widget.mainAction.icon,
|
||||||
|
backgroundColor: widget.mainAction.backgroundColor,
|
||||||
|
foregroundColor: widget.mainAction.foregroundColor,
|
||||||
|
heroTag: widget.heroTag,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modèle pour une action de FAB
|
||||||
|
class ModernFABAction {
|
||||||
|
const ModernFABAction({
|
||||||
|
required this.icon,
|
||||||
|
this.onPressed,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.foregroundColor,
|
||||||
|
this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final Color? foregroundColor;
|
||||||
|
final String? label;
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/theme/design_system.dart';
|
||||||
|
|
||||||
|
/// TabBar moderne avec animations et design professionnel
|
||||||
|
class ModernTabBar extends StatefulWidget implements PreferredSizeWidget {
|
||||||
|
const ModernTabBar({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.tabs,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TabController controller;
|
||||||
|
final List<ModernTab> tabs;
|
||||||
|
final ValueChanged<int>? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ModernTabBar> createState() => _ModernTabBarState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize => Size.fromHeight(DesignSystem.goldenWidth(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModernTabBarState extends State<ModernTabBar>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: DesignSystem.animationFast,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_scaleAnimation = Tween<double>(
|
||||||
|
begin: 1.0,
|
||||||
|
end: 0.95,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurve,
|
||||||
|
));
|
||||||
|
|
||||||
|
widget.controller.addListener(_onTabChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.controller.removeListener(_onTabChanged);
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTabChanged() {
|
||||||
|
if (mounted) {
|
||||||
|
_animationController.forward().then((_) {
|
||||||
|
_animationController.reverse();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: EdgeInsets.symmetric(
|
||||||
|
horizontal: DesignSystem.spacingLg,
|
||||||
|
vertical: DesignSystem.spacingSm,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.surfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
|
||||||
|
boxShadow: DesignSystem.shadowCard,
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.borderColor.withOpacity(0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
|
||||||
|
child: TabBar(
|
||||||
|
controller: widget.controller,
|
||||||
|
onTap: widget.onTap,
|
||||||
|
indicator: BoxDecoration(
|
||||||
|
gradient: DesignSystem.primaryGradient,
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
|
||||||
|
),
|
||||||
|
indicatorSize: TabBarIndicatorSize.tab,
|
||||||
|
indicatorPadding: EdgeInsets.all(DesignSystem.spacingXs),
|
||||||
|
labelColor: Colors.white,
|
||||||
|
unselectedLabelColor: AppTheme.textSecondary,
|
||||||
|
labelStyle: DesignSystem.labelLarge.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
unselectedLabelStyle: DesignSystem.labelLarge.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
dividerColor: Colors.transparent,
|
||||||
|
tabs: widget.tabs.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final tab = entry.value;
|
||||||
|
final isSelected = widget.controller.index == index;
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _scaleAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.scale(
|
||||||
|
scale: isSelected ? _scaleAnimation.value : 1.0,
|
||||||
|
child: _buildTab(tab, isSelected),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTab(ModernTab tab, bool isSelected) {
|
||||||
|
return Container(
|
||||||
|
height: DesignSystem.goldenWidth(50),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: DesignSystem.animationFast,
|
||||||
|
child: Icon(
|
||||||
|
tab.icon,
|
||||||
|
size: isSelected ? 20 : 18,
|
||||||
|
color: isSelected ? Colors.white : AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (tab.label != null) ...[
|
||||||
|
SizedBox(width: DesignSystem.spacingXs),
|
||||||
|
AnimatedDefaultTextStyle(
|
||||||
|
duration: DesignSystem.animationFast,
|
||||||
|
style: (isSelected ? DesignSystem.labelLarge : DesignSystem.labelMedium).copyWith(
|
||||||
|
color: isSelected ? Colors.white : AppTheme.textSecondary,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
),
|
||||||
|
child: Text(tab.label!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (tab.badge != null) ...[
|
||||||
|
SizedBox(width: DesignSystem.spacingXs),
|
||||||
|
_buildBadge(tab.badge!, isSelected),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBadge(String badge, bool isSelected) {
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: DesignSystem.animationFast,
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: DesignSystem.spacingXs,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white.withOpacity(0.2)
|
||||||
|
: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
badge,
|
||||||
|
style: DesignSystem.labelSmall.copyWith(
|
||||||
|
color: isSelected ? Colors.white : AppTheme.primaryColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modèle pour un onglet moderne
|
||||||
|
class ModernTab {
|
||||||
|
const ModernTab({
|
||||||
|
required this.icon,
|
||||||
|
this.label,
|
||||||
|
this.badge,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String? label;
|
||||||
|
final String? badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension pour créer facilement des onglets modernes
|
||||||
|
extension ModernTabExtension on Tab {
|
||||||
|
static ModernTab modern({
|
||||||
|
required IconData icon,
|
||||||
|
String? label,
|
||||||
|
String? badge,
|
||||||
|
}) {
|
||||||
|
return ModernTab(
|
||||||
|
icon: icon,
|
||||||
|
label: label,
|
||||||
|
badge: badge,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/theme/design_system.dart';
|
||||||
|
|
||||||
|
/// Graphique en barres professionnel avec animations et interactions
|
||||||
|
class ProfessionalBarChart extends StatefulWidget {
|
||||||
|
const ProfessionalBarChart({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.showGrid = true,
|
||||||
|
this.showValues = true,
|
||||||
|
this.animationDuration = const Duration(milliseconds: 1500),
|
||||||
|
this.barColor,
|
||||||
|
this.gradientColors,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<BarDataPoint> data;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final bool showGrid;
|
||||||
|
final bool showValues;
|
||||||
|
final Duration animationDuration;
|
||||||
|
final Color? barColor;
|
||||||
|
final List<Color>? gradientColors;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProfessionalBarChart> createState() => _ProfessionalBarChartState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfessionalBarChartState extends State<ProfessionalBarChart>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _animation;
|
||||||
|
int _touchedIndex = -1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: widget.animationDuration,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_animation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurve,
|
||||||
|
));
|
||||||
|
|
||||||
|
_animationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
SizedBox(height: DesignSystem.spacingLg),
|
||||||
|
Expanded(
|
||||||
|
child: _buildChart(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.title,
|
||||||
|
style: DesignSystem.titleLarge.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.subtitle != null) ...[
|
||||||
|
SizedBox(height: DesignSystem.spacingXs),
|
||||||
|
Text(
|
||||||
|
widget.subtitle!,
|
||||||
|
style: DesignSystem.bodyMedium.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChart() {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return BarChart(
|
||||||
|
BarChartData(
|
||||||
|
alignment: BarChartAlignment.spaceAround,
|
||||||
|
maxY: _getMaxY() * 1.2,
|
||||||
|
barTouchData: BarTouchData(
|
||||||
|
touchTooltipData: BarTouchTooltipData(
|
||||||
|
tooltipBgColor: AppTheme.textPrimary.withOpacity(0.9),
|
||||||
|
tooltipRoundedRadius: DesignSystem.radiusSm,
|
||||||
|
tooltipPadding: EdgeInsets.all(DesignSystem.spacingSm),
|
||||||
|
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||||
|
return BarTooltipItem(
|
||||||
|
'${widget.data[groupIndex].label}\n${rod.toY.toInt()}',
|
||||||
|
DesignSystem.labelMedium.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
touchCallback: (FlTouchEvent event, barTouchResponse) {
|
||||||
|
setState(() {
|
||||||
|
if (!event.isInterestedForInteractions ||
|
||||||
|
barTouchResponse == null ||
|
||||||
|
barTouchResponse.spot == null) {
|
||||||
|
_touchedIndex = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_touchedIndex = barTouchResponse.spot!.touchedBarGroupIndex;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
show: true,
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
getTitlesWidget: _buildBottomTitles,
|
||||||
|
reservedSize: 42,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
getTitlesWidget: _buildLeftTitles,
|
||||||
|
reservedSize: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: widget.showGrid,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: _getMaxY() / 5,
|
||||||
|
getDrawingHorizontalLine: (value) {
|
||||||
|
return FlLine(
|
||||||
|
color: AppTheme.borderColor.withOpacity(0.3),
|
||||||
|
strokeWidth: 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
barGroups: _buildBarGroups(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BarChartGroupData> _buildBarGroups() {
|
||||||
|
return widget.data.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final data = entry.value;
|
||||||
|
final isTouched = index == _touchedIndex;
|
||||||
|
|
||||||
|
return BarChartGroupData(
|
||||||
|
x: index,
|
||||||
|
barRods: [
|
||||||
|
BarChartRodData(
|
||||||
|
toY: data.value * _animation.value,
|
||||||
|
color: _getBarColor(index, isTouched),
|
||||||
|
width: isTouched ? 24 : 20,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(DesignSystem.radiusXs),
|
||||||
|
topRight: Radius.circular(DesignSystem.radiusXs),
|
||||||
|
),
|
||||||
|
gradient: widget.gradientColors != null ? LinearGradient(
|
||||||
|
begin: Alignment.bottomCenter,
|
||||||
|
end: Alignment.topCenter,
|
||||||
|
colors: widget.gradientColors!,
|
||||||
|
) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
showingTooltipIndicators: isTouched ? [0] : [],
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getBarColor(int index, bool isTouched) {
|
||||||
|
if (widget.barColor != null) {
|
||||||
|
return isTouched
|
||||||
|
? widget.barColor!
|
||||||
|
: widget.barColor!.withOpacity(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
final colors = DesignSystem.chartColors;
|
||||||
|
final color = colors[index % colors.length];
|
||||||
|
return isTouched ? color : color.withOpacity(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomTitles(double value, TitleMeta meta) {
|
||||||
|
if (value.toInt() >= widget.data.length) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final data = widget.data[value.toInt()];
|
||||||
|
return SideTitleWidget(
|
||||||
|
axisSide: meta.axisSide,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(top: DesignSystem.spacingXs),
|
||||||
|
child: Text(
|
||||||
|
data.label,
|
||||||
|
style: DesignSystem.labelSmall.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLeftTitles(double value, TitleMeta meta) {
|
||||||
|
return SideTitleWidget(
|
||||||
|
axisSide: meta.axisSide,
|
||||||
|
child: Text(
|
||||||
|
value.toInt().toString(),
|
||||||
|
style: DesignSystem.labelSmall.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _getMaxY() {
|
||||||
|
if (widget.data.isEmpty) return 10;
|
||||||
|
return widget.data.map((e) => e.value).reduce((a, b) => a > b ? a : b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modèle de données pour le graphique en barres
|
||||||
|
class BarDataPoint {
|
||||||
|
const BarDataPoint({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final double value;
|
||||||
|
final Color? color;
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/theme/design_system.dart';
|
||||||
|
|
||||||
|
/// Graphique linéaire professionnel avec animations et interactions
|
||||||
|
class ProfessionalLineChart extends StatefulWidget {
|
||||||
|
const ProfessionalLineChart({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.showGrid = true,
|
||||||
|
this.showDots = true,
|
||||||
|
this.showArea = false,
|
||||||
|
this.animationDuration = const Duration(milliseconds: 1500),
|
||||||
|
this.lineColor,
|
||||||
|
this.gradientColors,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<LineDataPoint> data;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final bool showGrid;
|
||||||
|
final bool showDots;
|
||||||
|
final bool showArea;
|
||||||
|
final Duration animationDuration;
|
||||||
|
final Color? lineColor;
|
||||||
|
final List<Color>? gradientColors;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProfessionalLineChart> createState() => _ProfessionalLineChartState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfessionalLineChartState extends State<ProfessionalLineChart>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _animation;
|
||||||
|
List<int> _showingTooltipOnSpots = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: widget.animationDuration,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_animation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurve,
|
||||||
|
));
|
||||||
|
|
||||||
|
_animationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
SizedBox(height: DesignSystem.spacingLg),
|
||||||
|
Expanded(
|
||||||
|
child: _buildChart(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.title,
|
||||||
|
style: DesignSystem.titleLarge.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.subtitle != null) ...[
|
||||||
|
SizedBox(height: DesignSystem.spacingXs),
|
||||||
|
Text(
|
||||||
|
widget.subtitle!,
|
||||||
|
style: DesignSystem.bodyMedium.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChart() {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return LineChart(
|
||||||
|
LineChartData(
|
||||||
|
lineTouchData: LineTouchData(
|
||||||
|
touchTooltipData: LineTouchTooltipData(
|
||||||
|
tooltipBgColor: AppTheme.textPrimary.withOpacity(0.9),
|
||||||
|
tooltipRoundedRadius: DesignSystem.radiusSm,
|
||||||
|
tooltipPadding: EdgeInsets.all(DesignSystem.spacingSm),
|
||||||
|
getTooltipItems: (List<LineBarSpot> touchedBarSpots) {
|
||||||
|
return touchedBarSpots.map((barSpot) {
|
||||||
|
final data = widget.data[barSpot.x.toInt()];
|
||||||
|
return LineTooltipItem(
|
||||||
|
'${data.label}\n${barSpot.y.toInt()}',
|
||||||
|
DesignSystem.labelMedium.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
handleBuiltInTouches: true,
|
||||||
|
getTouchedSpotIndicator: (LineChartBarData barData, List<int> spotIndexes) {
|
||||||
|
return spotIndexes.map((index) {
|
||||||
|
return TouchedSpotIndicatorData(
|
||||||
|
FlLine(
|
||||||
|
color: widget.lineColor ?? AppTheme.primaryColor,
|
||||||
|
strokeWidth: 2,
|
||||||
|
dashArray: [3, 3],
|
||||||
|
),
|
||||||
|
FlDotData(
|
||||||
|
getDotPainter: (spot, percent, barData, index) =>
|
||||||
|
FlDotCirclePainter(
|
||||||
|
radius: 6,
|
||||||
|
color: widget.lineColor ?? AppTheme.primaryColor,
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: widget.showGrid,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: _getMaxY() / 5,
|
||||||
|
getDrawingHorizontalLine: (value) {
|
||||||
|
return FlLine(
|
||||||
|
color: AppTheme.borderColor.withOpacity(0.3),
|
||||||
|
strokeWidth: 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
show: true,
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
getTitlesWidget: _buildBottomTitles,
|
||||||
|
reservedSize: 42,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
getTitlesWidget: _buildLeftTitles,
|
||||||
|
reservedSize: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
minX: 0,
|
||||||
|
maxX: widget.data.length.toDouble() - 1,
|
||||||
|
minY: 0,
|
||||||
|
maxY: _getMaxY() * 1.2,
|
||||||
|
lineBarsData: [
|
||||||
|
_buildLineBarData(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LineChartBarData _buildLineBarData() {
|
||||||
|
final spots = widget.data.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final data = entry.value;
|
||||||
|
return FlSpot(index.toDouble(), data.value * _animation.value);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return LineChartBarData(
|
||||||
|
spots: spots,
|
||||||
|
isCurved: true,
|
||||||
|
curveSmoothness: 0.3,
|
||||||
|
color: widget.lineColor ?? AppTheme.primaryColor,
|
||||||
|
barWidth: 3,
|
||||||
|
isStrokeCapRound: true,
|
||||||
|
dotData: FlDotData(
|
||||||
|
show: widget.showDots,
|
||||||
|
getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
|
||||||
|
radius: 4,
|
||||||
|
color: widget.lineColor ?? AppTheme.primaryColor,
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
belowBarData: widget.showArea ? BarAreaData(
|
||||||
|
show: true,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: widget.gradientColors ?? [
|
||||||
|
(widget.lineColor ?? AppTheme.primaryColor).withOpacity(0.3),
|
||||||
|
(widget.lineColor ?? AppTheme.primaryColor).withOpacity(0.05),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) : BarAreaData(show: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomTitles(double value, TitleMeta meta) {
|
||||||
|
if (value.toInt() >= widget.data.length) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final data = widget.data[value.toInt()];
|
||||||
|
return SideTitleWidget(
|
||||||
|
axisSide: meta.axisSide,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(top: DesignSystem.spacingXs),
|
||||||
|
child: Text(
|
||||||
|
data.label,
|
||||||
|
style: DesignSystem.labelSmall.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLeftTitles(double value, TitleMeta meta) {
|
||||||
|
return SideTitleWidget(
|
||||||
|
axisSide: meta.axisSide,
|
||||||
|
child: Text(
|
||||||
|
value.toInt().toString(),
|
||||||
|
style: DesignSystem.labelSmall.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _getMaxY() {
|
||||||
|
if (widget.data.isEmpty) return 10;
|
||||||
|
return widget.data.map((e) => e.value).reduce((a, b) => a > b ? a : b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modèle de données pour le graphique linéaire
|
||||||
|
class LineDataPoint {
|
||||||
|
const LineDataPoint({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final double value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/theme/design_system.dart';
|
||||||
|
|
||||||
|
/// Graphique en secteurs professionnel avec animations et légendes
|
||||||
|
class ProfessionalPieChart extends StatefulWidget {
|
||||||
|
const ProfessionalPieChart({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.centerText,
|
||||||
|
this.showLegend = true,
|
||||||
|
this.showPercentages = true,
|
||||||
|
this.animationDuration = const Duration(milliseconds: 1500),
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<ChartDataPoint> data;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final String? centerText;
|
||||||
|
final bool showLegend;
|
||||||
|
final bool showPercentages;
|
||||||
|
final Duration animationDuration;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProfessionalPieChart> createState() => _ProfessionalPieChartState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfessionalPieChartState extends State<ProfessionalPieChart>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _animation;
|
||||||
|
int _touchedIndex = -1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: widget.animationDuration,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_animation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurve,
|
||||||
|
));
|
||||||
|
|
||||||
|
_animationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
SizedBox(height: DesignSystem.spacingLg),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: _buildChart(),
|
||||||
|
),
|
||||||
|
if (widget.showLegend) ...[
|
||||||
|
SizedBox(width: DesignSystem.spacingLg),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _buildLegend(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.title,
|
||||||
|
style: DesignSystem.titleLarge.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.subtitle != null) ...[
|
||||||
|
SizedBox(height: DesignSystem.spacingXs),
|
||||||
|
Text(
|
||||||
|
widget.subtitle!,
|
||||||
|
style: DesignSystem.bodyMedium.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChart() {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Container(
|
||||||
|
height: 140, // Hauteur encore plus réduite
|
||||||
|
padding: const EdgeInsets.all(4), // Padding minimal pour contenir le graphique
|
||||||
|
child: PieChart(
|
||||||
|
PieChartData(
|
||||||
|
pieTouchData: PieTouchData(
|
||||||
|
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
||||||
|
setState(() {
|
||||||
|
if (!event.isInterestedForInteractions ||
|
||||||
|
pieTouchResponse == null ||
|
||||||
|
pieTouchResponse.touchedSection == null) {
|
||||||
|
_touchedIndex = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
sectionsSpace: 1, // Espace réduit entre sections
|
||||||
|
centerSpaceRadius: widget.centerText != null ? 45 : 30, // Rayon central réduit
|
||||||
|
sections: _buildSections(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PieChartSectionData> _buildSections() {
|
||||||
|
final total = widget.data.fold<double>(0, (sum, item) => sum + item.value);
|
||||||
|
|
||||||
|
return widget.data.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final data = entry.value;
|
||||||
|
final isTouched = index == _touchedIndex;
|
||||||
|
final percentage = (data.value / total * 100);
|
||||||
|
|
||||||
|
return PieChartSectionData(
|
||||||
|
color: data.color,
|
||||||
|
value: data.value * _animation.value,
|
||||||
|
title: widget.showPercentages ? '${percentage.toStringAsFixed(1)}%' : '',
|
||||||
|
radius: isTouched ? 70 : 60,
|
||||||
|
titleStyle: DesignSystem.labelMedium.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: Colors.black.withOpacity(0.3),
|
||||||
|
offset: const Offset(1, 1),
|
||||||
|
blurRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
titlePositionPercentageOffset: 0.6,
|
||||||
|
badgeWidget: isTouched ? _buildBadge(data) : null,
|
||||||
|
badgePositionPercentageOffset: 1.3,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBadge(ChartDataPoint data) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: DesignSystem.spacingSm,
|
||||||
|
vertical: DesignSystem.spacingXs,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: data.color,
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
|
||||||
|
boxShadow: DesignSystem.shadowCard,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
data.value.toInt().toString(),
|
||||||
|
style: DesignSystem.labelMedium.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLegend() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (widget.centerText != null) ...[
|
||||||
|
_buildCenterInfo(),
|
||||||
|
SizedBox(height: DesignSystem.spacingLg),
|
||||||
|
],
|
||||||
|
...widget.data.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final data = entry.value;
|
||||||
|
final isSelected = index == _touchedIndex;
|
||||||
|
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: DesignSystem.animationFast,
|
||||||
|
margin: EdgeInsets.only(bottom: DesignSystem.spacingSm),
|
||||||
|
padding: EdgeInsets.all(DesignSystem.spacingSm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? data.color.withOpacity(0.1) : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
|
||||||
|
border: isSelected ? Border.all(
|
||||||
|
color: data.color.withOpacity(0.3),
|
||||||
|
width: 1,
|
||||||
|
) : null,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: data.color,
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusXs),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: DesignSystem.spacingSm),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
data.label,
|
||||||
|
style: DesignSystem.labelLarge.copyWith(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
data.value.toInt().toString(),
|
||||||
|
style: DesignSystem.labelMedium.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCenterInfo() {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(DesignSystem.spacingMd),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Total',
|
||||||
|
style: DesignSystem.labelMedium.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: DesignSystem.spacingXs),
|
||||||
|
Text(
|
||||||
|
widget.centerText!,
|
||||||
|
style: DesignSystem.headlineMedium.copyWith(
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modèle de données pour le graphique en secteurs
|
||||||
|
class ChartDataPoint {
|
||||||
|
const ChartDataPoint({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final double value;
|
||||||
|
final Color color;
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/theme/design_system.dart';
|
||||||
|
|
||||||
|
/// Grille de statistiques compacte pour mobile
|
||||||
|
class StatsGridCard extends StatefulWidget {
|
||||||
|
const StatsGridCard({
|
||||||
|
super.key,
|
||||||
|
required this.stats,
|
||||||
|
this.crossAxisCount = 2,
|
||||||
|
this.childAspectRatio = 1.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> stats;
|
||||||
|
final int crossAxisCount;
|
||||||
|
final double childAspectRatio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatsGridCard> createState() => _StatsGridCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatsGridCardState extends State<StatsGridCard>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late List<AnimationController> _animationControllers;
|
||||||
|
late List<Animation<double>> _scaleAnimations;
|
||||||
|
late List<Animation<Offset>> _slideAnimations;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeAnimations();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeAnimations() {
|
||||||
|
const itemCount = 4; // Nombre de statistiques
|
||||||
|
_animationControllers = List.generate(
|
||||||
|
itemCount,
|
||||||
|
(index) => AnimationController(
|
||||||
|
duration: Duration(
|
||||||
|
milliseconds: DesignSystem.animationMedium.inMilliseconds + (index * 100),
|
||||||
|
),
|
||||||
|
vsync: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_scaleAnimations = _animationControllers.map((controller) {
|
||||||
|
return Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: controller,
|
||||||
|
curve: DesignSystem.animationCurveEnter,
|
||||||
|
));
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_slideAnimations = _animationControllers.map((controller) {
|
||||||
|
return Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.5),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: controller,
|
||||||
|
curve: DesignSystem.animationCurveEnter,
|
||||||
|
));
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Démarrer les animations en cascade
|
||||||
|
for (int i = 0; i < _animationControllers.length; i++) {
|
||||||
|
Future.delayed(Duration(milliseconds: i * 100), () {
|
||||||
|
if (mounted) {
|
||||||
|
_animationControllers[i].forward();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final controller in _animationControllers) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final statsItems = [
|
||||||
|
_StatItem(
|
||||||
|
title: 'Total Membres',
|
||||||
|
value: widget.stats['totalMembres'].toString(),
|
||||||
|
icon: Icons.people,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
trend: '+${widget.stats['nouveauxCeMois']}',
|
||||||
|
trendPositive: true,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
title: 'Membres Actifs',
|
||||||
|
value: widget.stats['membresActifs'].toString(),
|
||||||
|
icon: Icons.person,
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
trend: '${widget.stats['tauxActivite']}%',
|
||||||
|
trendPositive: widget.stats['tauxActivite'] >= 70,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
title: 'Nouveaux ce mois',
|
||||||
|
value: widget.stats['nouveauxCeMois'].toString(),
|
||||||
|
icon: Icons.person_add,
|
||||||
|
color: AppTheme.infoColor,
|
||||||
|
trend: 'Ce mois',
|
||||||
|
trendPositive: widget.stats['nouveauxCeMois'] > 0,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
title: 'Taux d\'activité',
|
||||||
|
value: '${widget.stats['tauxActivite']}%',
|
||||||
|
icon: Icons.trending_up,
|
||||||
|
color: AppTheme.warningColor,
|
||||||
|
trend: widget.stats['tauxActivite'] >= 70 ? 'Excellent' : 'Moyen',
|
||||||
|
trendPositive: widget.stats['tauxActivite'] >= 70,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: widget.crossAxisCount,
|
||||||
|
childAspectRatio: widget.childAspectRatio,
|
||||||
|
crossAxisSpacing: DesignSystem.spacingMd,
|
||||||
|
mainAxisSpacing: DesignSystem.spacingMd,
|
||||||
|
),
|
||||||
|
itemCount: statsItems.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animationControllers[index],
|
||||||
|
builder: (context, child) {
|
||||||
|
return SlideTransition(
|
||||||
|
position: _slideAnimations[index],
|
||||||
|
child: ScaleTransition(
|
||||||
|
scale: _scaleAnimations[index],
|
||||||
|
child: _buildStatCard(statsItems[index]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatCard(_StatItem item) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(DesignSystem.spacingMd),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.surfaceLight,
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
|
||||||
|
boxShadow: DesignSystem.shadowCard,
|
||||||
|
border: Border.all(
|
||||||
|
color: item.color.withOpacity(0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.all(DesignSystem.spacingSm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: item.color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
item.icon,
|
||||||
|
color: item.color,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: DesignSystem.spacingXs,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: item.trendPositive
|
||||||
|
? AppTheme.successColor.withOpacity(0.1)
|
||||||
|
: AppTheme.errorColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.trend,
|
||||||
|
style: DesignSystem.labelSmall.copyWith(
|
||||||
|
color: item.trendPositive
|
||||||
|
? AppTheme.successColor
|
||||||
|
: AppTheme.errorColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: DesignSystem.spacingSm),
|
||||||
|
Text(
|
||||||
|
item.value,
|
||||||
|
style: DesignSystem.headlineMedium.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: DesignSystem.spacingXs),
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style: DesignSystem.labelMedium.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modèle pour un élément de statistique
|
||||||
|
class _StatItem {
|
||||||
|
const _StatItem({
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.trend,
|
||||||
|
required this.trendPositive,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final String trend;
|
||||||
|
final bool trendPositive;
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
|
import '../../../../shared/theme/design_system.dart';
|
||||||
|
|
||||||
|
/// Card de vue d'ensemble des statistiques avec design professionnel
|
||||||
|
class StatsOverviewCard extends StatefulWidget {
|
||||||
|
const StatsOverviewCard({
|
||||||
|
super.key,
|
||||||
|
required this.stats,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> stats;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatsOverviewCard> createState() => _StatsOverviewCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatsOverviewCardState extends State<StatsOverviewCard>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _fadeAnimation;
|
||||||
|
late Animation<Offset> _slideAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: DesignSystem.animationMedium,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_fadeAnimation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurve,
|
||||||
|
));
|
||||||
|
|
||||||
|
_slideAnimation = Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.3),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: DesignSystem.animationCurveEnter,
|
||||||
|
));
|
||||||
|
|
||||||
|
_animationController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return SlideTransition(
|
||||||
|
position: _slideAnimation,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: _fadeAnimation,
|
||||||
|
child: _buildCard(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCard() {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(DesignSystem.spacingLg),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: DesignSystem.primaryGradient,
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
|
||||||
|
boxShadow: DesignSystem.shadowCard,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
SizedBox(height: DesignSystem.spacingLg),
|
||||||
|
_buildMainStats(),
|
||||||
|
SizedBox(height: DesignSystem.spacingLg),
|
||||||
|
_buildSecondaryStats(),
|
||||||
|
SizedBox(height: DesignSystem.spacingMd),
|
||||||
|
_buildProgressIndicator(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Vue d\'ensemble',
|
||||||
|
style: DesignSystem.titleLarge.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: DesignSystem.spacingXs),
|
||||||
|
Text(
|
||||||
|
'Statistiques générales',
|
||||||
|
style: DesignSystem.bodyMedium.copyWith(
|
||||||
|
color: Colors.white.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.all(DesignSystem.spacingSm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.analytics,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMainStats() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
'Total Membres',
|
||||||
|
widget.stats['totalMembres'].toString(),
|
||||||
|
Icons.people,
|
||||||
|
Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: DesignSystem.spacingLg),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
'Membres Actifs',
|
||||||
|
widget.stats['membresActifs'].toString(),
|
||||||
|
Icons.person,
|
||||||
|
Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSecondaryStats() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
'Nouveaux ce mois',
|
||||||
|
widget.stats['nouveauxCeMois'].toString(),
|
||||||
|
Icons.person_add,
|
||||||
|
Colors.white.withOpacity(0.9),
|
||||||
|
isSecondary: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: DesignSystem.spacingLg),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatItem(
|
||||||
|
'Taux d\'activité',
|
||||||
|
'${widget.stats['tauxActivite']}%',
|
||||||
|
Icons.trending_up,
|
||||||
|
Colors.white.withOpacity(0.9),
|
||||||
|
isSecondary: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatItem(
|
||||||
|
String label,
|
||||||
|
String value,
|
||||||
|
IconData icon,
|
||||||
|
Color color, {
|
||||||
|
bool isSecondary = false,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
color: color,
|
||||||
|
size: isSecondary ? 16 : 20,
|
||||||
|
),
|
||||||
|
SizedBox(width: DesignSystem.spacingXs),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: (isSecondary ? DesignSystem.labelMedium : DesignSystem.labelLarge).copyWith(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: DesignSystem.spacingXs),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: (isSecondary ? DesignSystem.headlineMedium : DesignSystem.displayMedium).copyWith(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: isSecondary ? 20 : 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProgressIndicator() {
|
||||||
|
final tauxActivite = widget.stats['tauxActivite'] as int;
|
||||||
|
final progress = tauxActivite / 100.0;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Engagement communautaire',
|
||||||
|
style: DesignSystem.labelMedium.copyWith(
|
||||||
|
color: Colors.white.withOpacity(0.9),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'$tauxActivite%',
|
||||||
|
style: DesignSystem.labelMedium.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: DesignSystem.spacingXs),
|
||||||
|
Container(
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusXs),
|
||||||
|
),
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
widthFactor: progress,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(DesignSystem.radiusXs),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.white.withOpacity(0.3),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ import 'package:flutter/services.dart';
|
|||||||
import '../../../../shared/theme/app_theme.dart';
|
import '../../../../shared/theme/app_theme.dart';
|
||||||
import '../../../../shared/widgets/coming_soon_page.dart';
|
import '../../../../shared/widgets/coming_soon_page.dart';
|
||||||
import '../../../../shared/widgets/buttons/buttons.dart';
|
import '../../../../shared/widgets/buttons/buttons.dart';
|
||||||
import '../../../dashboard/presentation/pages/enhanced_dashboard.dart';
|
import '../../../dashboard/presentation/pages/dashboard_page.dart';
|
||||||
import '../../../members/presentation/pages/membres_list_page.dart';
|
import '../../../members/presentation/pages/membres_list_page.dart';
|
||||||
|
import '../../../cotisations/presentation/pages/cotisations_list_page.dart';
|
||||||
import '../widgets/custom_bottom_nav_bar.dart';
|
import '../widgets/custom_bottom_nav_bar.dart';
|
||||||
|
|
||||||
class MainNavigation extends StatefulWidget {
|
class MainNavigation extends StatefulWidget {
|
||||||
@@ -85,9 +86,7 @@ class _MainNavigationState extends State<MainNavigation>
|
|||||||
body: IndexedStack(
|
body: IndexedStack(
|
||||||
index: _currentIndex,
|
index: _currentIndex,
|
||||||
children: [
|
children: [
|
||||||
EnhancedDashboard(
|
const DashboardPage(),
|
||||||
onNavigateToTab: _onTabTapped,
|
|
||||||
),
|
|
||||||
_buildMembresPage(),
|
_buildMembresPage(),
|
||||||
_buildCotisationsPage(),
|
_buildCotisationsPage(),
|
||||||
_buildEventsPage(),
|
_buildEventsPage(),
|
||||||
@@ -209,20 +208,7 @@ class _MainNavigationState extends State<MainNavigation>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCotisationsPage() {
|
Widget _buildCotisationsPage() {
|
||||||
return const ComingSoonPage(
|
return const CotisationsListPage();
|
||||||
title: 'Module Cotisations',
|
|
||||||
description: 'Suivi et gestion 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 _buildEventsPage() {
|
Widget _buildEventsPage() {
|
||||||
|
|||||||
@@ -88,19 +88,19 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Séquence d'animations
|
// Séquence d'animations avec vérification mounted
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
_logoController.forward();
|
if (mounted) _logoController.forward();
|
||||||
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
_textController.forward();
|
if (mounted) _textController.forward();
|
||||||
|
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
_progressController.forward();
|
if (mounted) _progressController.forward();
|
||||||
|
|
||||||
// Attendre la fin de toutes les animations + temps de chargement
|
// Attendre la fin de toutes les animations + temps de chargement
|
||||||
await Future.delayed(const Duration(milliseconds: 2000));
|
await Future.delayed(const Duration(milliseconds: 2000));
|
||||||
|
|
||||||
// Le splash screen sera remplacé automatiquement par l'AppWrapper
|
// Le splash screen sera remplacé automatiquement par l'AppWrapper
|
||||||
// basé sur l'état d'authentification
|
// basé sur l'état d'authentification
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
|
|
||||||
import 'core/auth/bloc/temp_auth_bloc.dart';
|
import 'core/auth/bloc/temp_auth_bloc.dart';
|
||||||
import 'core/auth/bloc/auth_event.dart';
|
import 'core/auth/bloc/auth_event.dart';
|
||||||
import 'core/auth/services/temp_auth_service.dart';
|
import 'core/auth/services/temp_auth_service.dart';
|
||||||
import 'core/di/injection.dart';
|
import 'core/di/injection.dart';
|
||||||
import 'shared/theme/app_theme.dart';
|
import 'shared/theme/app_theme.dart';
|
||||||
import 'app_temp.dart';
|
import 'app.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialisation des données de localisation
|
||||||
|
await initializeDateFormatting('fr_FR', null);
|
||||||
|
|
||||||
// Configuration de l'injection de dépendances
|
// Configuration de l'injection de dépendances
|
||||||
await configureDependencies();
|
await configureDependencies();
|
||||||
|
|
||||||
@@ -19,7 +23,7 @@ void main() async {
|
|||||||
await _configureApp();
|
await _configureApp();
|
||||||
|
|
||||||
// Lancement de l'application
|
// Lancement de l'application
|
||||||
runApp(const UnionFlowTempApp());
|
runApp(const UnionFlowApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configure les paramètres globaux de l'application
|
/// Configure les paramètres globaux de l'application
|
||||||
@@ -28,7 +32,7 @@ Future<void> _configureApp() async {
|
|||||||
await SystemChrome.setPreferredOrientations([
|
await SystemChrome.setPreferredOrientations([
|
||||||
DeviceOrientation.portraitUp,
|
DeviceOrientation.portraitUp,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Configuration de la barre de statut
|
// Configuration de la barre de statut
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
const SystemUiOverlayStyle(
|
const SystemUiOverlayStyle(
|
||||||
@@ -41,9 +45,9 @@ Future<void> _configureApp() async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Application principale temporaire
|
/// Application principale
|
||||||
class UnionFlowTempApp extends StatelessWidget {
|
class UnionFlowApp extends StatelessWidget {
|
||||||
const UnionFlowTempApp({super.key});
|
const UnionFlowApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -57,23 +61,23 @@ class UnionFlowTempApp extends StatelessWidget {
|
|||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'UnionFlow',
|
title: 'UnionFlow',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
|
||||||
// Configuration du thème
|
// Configuration du thème
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
darkTheme: AppTheme.darkTheme,
|
darkTheme: AppTheme.darkTheme,
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
|
|
||||||
// Configuration de la localisation
|
// Configuration de la localisation
|
||||||
locale: const Locale('fr', 'FR'),
|
locale: const Locale('fr', 'FR'),
|
||||||
|
|
||||||
// Application principale
|
// Application principale
|
||||||
home: const AppTempWrapper(),
|
home: const AppWrapper(),
|
||||||
|
|
||||||
// Builder global pour gérer les erreurs
|
// Builder global pour gérer les erreurs
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return MediaQuery(
|
return MediaQuery(
|
||||||
data: MediaQuery.of(context).copyWith(
|
data: MediaQuery.of(context).copyWith(
|
||||||
textScaler: TextScaler.linear(1.0),
|
textScaler: const TextScaler.linear(1.0),
|
||||||
),
|
),
|
||||||
child: child ?? const SizedBox(),
|
child: child ?? const SizedBox(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
|
|
||||||
import 'core/auth/bloc/temp_auth_bloc.dart';
|
|
||||||
import 'core/auth/bloc/auth_event.dart';
|
|
||||||
import 'core/auth/services/temp_auth_service.dart';
|
|
||||||
import 'shared/theme/app_theme.dart';
|
|
||||||
import 'app_temp.dart';
|
|
||||||
|
|
||||||
void main() async {
|
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
// Configuration du système
|
|
||||||
await _configureApp();
|
|
||||||
|
|
||||||
// Lancement de l'application
|
|
||||||
runApp(const UnionFlowTempApp());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configure les paramètres globaux de l'application
|
|
||||||
Future<void> _configureApp() async {
|
|
||||||
// Configuration de l'orientation
|
|
||||||
await SystemChrome.setPreferredOrientations([
|
|
||||||
DeviceOrientation.portraitUp,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Configuration de la barre de statut
|
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
|
||||||
const SystemUiOverlayStyle(
|
|
||||||
statusBarColor: Colors.transparent,
|
|
||||||
statusBarIconBrightness: Brightness.dark,
|
|
||||||
statusBarBrightness: Brightness.light,
|
|
||||||
systemNavigationBarColor: Colors.white,
|
|
||||||
systemNavigationBarIconBrightness: Brightness.dark,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Application principale temporaire
|
|
||||||
class UnionFlowTempApp extends StatelessWidget {
|
|
||||||
const UnionFlowTempApp({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocProvider<TempAuthBloc>(
|
|
||||||
create: (context) {
|
|
||||||
final authService = TempAuthService();
|
|
||||||
final authBloc = TempAuthBloc(authService);
|
|
||||||
authBloc.add(const AuthInitializeRequested());
|
|
||||||
return authBloc;
|
|
||||||
},
|
|
||||||
child: MaterialApp(
|
|
||||||
title: 'UnionFlow',
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
|
|
||||||
// Configuration du thème
|
|
||||||
theme: AppTheme.lightTheme,
|
|
||||||
darkTheme: AppTheme.darkTheme,
|
|
||||||
themeMode: ThemeMode.system,
|
|
||||||
|
|
||||||
// Configuration de la localisation
|
|
||||||
locale: const Locale('fr', 'FR'),
|
|
||||||
|
|
||||||
// Application principale
|
|
||||||
home: const AppTempWrapper(),
|
|
||||||
|
|
||||||
// Builder global pour gérer les erreurs
|
|
||||||
builder: (context, child) {
|
|
||||||
return MediaQuery(
|
|
||||||
data: MediaQuery.of(context).copyWith(
|
|
||||||
textScaler: TextScaler.linear(1.0),
|
|
||||||
),
|
|
||||||
child: child ?? const SizedBox(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
|
|
||||||
import 'core/auth/bloc/temp_auth_bloc.dart';
|
|
||||||
import 'core/auth/bloc/auth_event.dart';
|
|
||||||
import 'core/auth/services/temp_auth_service.dart';
|
|
||||||
import 'core/di/injection.dart';
|
|
||||||
|
|
||||||
import 'shared/theme/app_theme.dart';
|
|
||||||
import 'app_ultra_simple.dart';
|
|
||||||
|
|
||||||
void main() async {
|
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
// Configuration de l'injection de dépendances
|
|
||||||
await configureDependencies();
|
|
||||||
|
|
||||||
// Configuration du système
|
|
||||||
await _configureApp();
|
|
||||||
|
|
||||||
// Lancement de l'application
|
|
||||||
runApp(const UnionFlowUltraSimpleApp());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configure les paramètres globaux de l'application
|
|
||||||
Future<void> _configureApp() async {
|
|
||||||
// Configuration de l'orientation
|
|
||||||
await SystemChrome.setPreferredOrientations([
|
|
||||||
DeviceOrientation.portraitUp,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Configuration de la barre de statut
|
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
|
||||||
const SystemUiOverlayStyle(
|
|
||||||
statusBarColor: Colors.transparent,
|
|
||||||
statusBarIconBrightness: Brightness.dark,
|
|
||||||
statusBarBrightness: Brightness.light,
|
|
||||||
systemNavigationBarColor: Colors.white,
|
|
||||||
systemNavigationBarIconBrightness: Brightness.dark,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classe BLoC ultra-simple qui utilise UltraSimpleAuthService
|
|
||||||
class UltraSimpleAuthBloc extends TempAuthBloc {
|
|
||||||
UltraSimpleAuthBloc(TempAuthService authService) : super(authService);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Application principale ultra-simple
|
|
||||||
class UnionFlowUltraSimpleApp extends StatelessWidget {
|
|
||||||
const UnionFlowUltraSimpleApp({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return BlocProvider<UltraSimpleAuthBloc>(
|
|
||||||
create: (context) {
|
|
||||||
final authService = TempAuthService();
|
|
||||||
final authBloc = UltraSimpleAuthBloc(authService);
|
|
||||||
authBloc.add(const AuthInitializeRequested());
|
|
||||||
return authBloc;
|
|
||||||
},
|
|
||||||
child: MaterialApp(
|
|
||||||
title: 'UnionFlow',
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
|
|
||||||
// Configuration du thème
|
|
||||||
theme: AppTheme.lightTheme,
|
|
||||||
|
|
||||||
// Configuration de la localisation
|
|
||||||
locale: const Locale('fr', 'FR'),
|
|
||||||
|
|
||||||
// Application principale
|
|
||||||
home: const UltraSimpleAppWrapper(),
|
|
||||||
|
|
||||||
// Builder global pour gérer les erreurs
|
|
||||||
builder: (context, child) {
|
|
||||||
return MediaQuery(
|
|
||||||
data: MediaQuery.of(context).copyWith(
|
|
||||||
textScaler: TextScaler.linear(1.0),
|
|
||||||
),
|
|
||||||
child: child ?? const SizedBox(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
263
unionflow-mobile-apps/lib/shared/theme/design_system.dart
Normal file
263
unionflow-mobile-apps/lib/shared/theme/design_system.dart
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'app_theme.dart';
|
||||||
|
|
||||||
|
/// Design System UnionFlow basé sur le nombre d'or et Material Design 3
|
||||||
|
class DesignSystem {
|
||||||
|
// === NOMBRE D'OR ET PROPORTIONS ===
|
||||||
|
static const double goldenRatio = 1.618;
|
||||||
|
static const double inverseGoldenRatio = 0.618;
|
||||||
|
|
||||||
|
// === ESPACEMENTS BASÉS SUR LE NOMBRE D'OR ===
|
||||||
|
static const double baseUnit = 8.0;
|
||||||
|
|
||||||
|
// Espacements principaux (progression géométrique basée sur le nombre d'or)
|
||||||
|
static const double spacing2xs = baseUnit * 0.5; // 4px
|
||||||
|
static const double spacingXs = baseUnit; // 8px
|
||||||
|
static const double spacingSm = baseUnit * 1.5; // 12px
|
||||||
|
static const double spacingMd = baseUnit * 2; // 16px
|
||||||
|
static const double spacingLg = baseUnit * 3; // 24px
|
||||||
|
static const double spacingXl = baseUnit * 4; // 32px
|
||||||
|
static const double spacing2xl = baseUnit * 6; // 48px
|
||||||
|
static const double spacing3xl = baseUnit * 8; // 64px
|
||||||
|
|
||||||
|
// Espacements spéciaux basés sur le nombre d'or
|
||||||
|
static const double spacingGolden = spacingMd * goldenRatio; // ~26px
|
||||||
|
static const double spacingGoldenLarge = spacingLg * goldenRatio; // ~39px
|
||||||
|
|
||||||
|
// === RAYONS DE BORDURE ===
|
||||||
|
static const double radiusXs = 4.0;
|
||||||
|
static const double radiusSm = 8.0;
|
||||||
|
static const double radiusMd = 12.0;
|
||||||
|
static const double radiusLg = 16.0;
|
||||||
|
static const double radiusXl = 20.0;
|
||||||
|
static const double radius2xl = 24.0;
|
||||||
|
|
||||||
|
// === ÉLÉVATIONS ET OMBRES ===
|
||||||
|
static const double elevationCard = 2.0;
|
||||||
|
static const double elevationModal = 8.0;
|
||||||
|
static const double elevationAppBar = 0.0;
|
||||||
|
|
||||||
|
// Ombres personnalisées
|
||||||
|
static List<BoxShadow> get shadowCard => [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.04),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.02),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<BoxShadow> get shadowCardHover => [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.08),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.04),
|
||||||
|
blurRadius: 32,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<BoxShadow> get shadowModal => [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.12),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.08),
|
||||||
|
blurRadius: 48,
|
||||||
|
offset: const Offset(0, 16),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// === TYPOGRAPHIE AVANCÉE ===
|
||||||
|
static const TextStyle displayLarge = TextStyle(
|
||||||
|
fontSize: 40,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
height: 1.2,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle displayMedium = TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.25,
|
||||||
|
height: 1.25,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle headlineLarge = TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0,
|
||||||
|
height: 1.3,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle headlineMedium = TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0,
|
||||||
|
height: 1.33,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle titleLarge = TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0,
|
||||||
|
height: 1.4,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle titleMedium = TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
height: 1.5,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle bodyLarge = TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: 0.15,
|
||||||
|
height: 1.5,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle bodyMedium = TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: 0.25,
|
||||||
|
height: 1.43,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle labelLarge = TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
height: 1.43,
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle labelMedium = TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
height: 1.33,
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const TextStyle labelSmall = TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
height: 1.2,
|
||||||
|
color: AppTheme.textHint,
|
||||||
|
);
|
||||||
|
|
||||||
|
// === COULEURS ÉTENDUES ===
|
||||||
|
// Palette de couleurs pour les graphiques (harmonieuse et accessible)
|
||||||
|
static const List<Color> chartColors = [
|
||||||
|
Color(0xFF2196F3), // Bleu principal
|
||||||
|
Color(0xFF4CAF50), // Vert
|
||||||
|
Color(0xFFFF9800), // Orange
|
||||||
|
Color(0xFF9C27B0), // Violet
|
||||||
|
Color(0xFFF44336), // Rouge
|
||||||
|
Color(0xFF00BCD4), // Cyan
|
||||||
|
Color(0xFFFFEB3B), // Jaune
|
||||||
|
Color(0xFF795548), // Marron
|
||||||
|
Color(0xFF607D8B), // Bleu gris
|
||||||
|
Color(0xFFE91E63), // Rose
|
||||||
|
];
|
||||||
|
|
||||||
|
// Couleurs de gradient
|
||||||
|
static const LinearGradient primaryGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
AppTheme.primaryColor,
|
||||||
|
AppTheme.primaryLight,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient successGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
AppTheme.successColor,
|
||||||
|
AppTheme.secondaryLight,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient warningGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
AppTheme.warningColor,
|
||||||
|
Color(0xFFFFB74D),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
static const LinearGradient errorGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
AppTheme.errorColor,
|
||||||
|
Color(0xFFEF5350),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// === ANIMATIONS ET TRANSITIONS ===
|
||||||
|
static const Duration animationFast = Duration(milliseconds: 150);
|
||||||
|
static const Duration animationMedium = Duration(milliseconds: 300);
|
||||||
|
static const Duration animationSlow = Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
static const Curve animationCurve = Curves.easeInOutCubic;
|
||||||
|
static const Curve animationCurveEnter = Curves.easeOut;
|
||||||
|
static const Curve animationCurveExit = Curves.easeIn;
|
||||||
|
|
||||||
|
// === BREAKPOINTS RESPONSIVE ===
|
||||||
|
static const double breakpointMobile = 480;
|
||||||
|
static const double breakpointTablet = 768;
|
||||||
|
static const double breakpointDesktop = 1024;
|
||||||
|
|
||||||
|
// === UTILITAIRES ===
|
||||||
|
static bool isMobile(BuildContext context) {
|
||||||
|
return MediaQuery.of(context).size.width < breakpointMobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isTablet(BuildContext context) {
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
return width >= breakpointMobile && width < breakpointDesktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isDesktop(BuildContext context) {
|
||||||
|
return MediaQuery.of(context).size.width >= breakpointDesktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcul de dimensions basées sur le nombre d'or
|
||||||
|
static double goldenWidth(double height) => height * goldenRatio;
|
||||||
|
static double goldenHeight(double width) => width * inverseGoldenRatio;
|
||||||
|
|
||||||
|
// Espacement adaptatif basé sur la taille d'écran
|
||||||
|
static double adaptiveSpacing(BuildContext context, {
|
||||||
|
double mobile = spacingMd,
|
||||||
|
double tablet = spacingLg,
|
||||||
|
double desktop = spacingXl,
|
||||||
|
}) {
|
||||||
|
if (isMobile(context)) return mobile;
|
||||||
|
if (isTablet(context)) return tablet;
|
||||||
|
return desktop;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,11 +22,17 @@ class ComingSoonPage extends StatelessWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.backgroundLight,
|
backgroundColor: AppTheme.backgroundLight,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: ConstrainedBox(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
constraints: BoxConstraints(
|
||||||
children: [
|
minHeight: MediaQuery.of(context).size.height -
|
||||||
|
MediaQuery.of(context).padding.top -
|
||||||
|
MediaQuery.of(context).padding.bottom - 48, // padding
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
// Icône principale
|
// Icône principale
|
||||||
Container(
|
Container(
|
||||||
width: 120,
|
width: 120,
|
||||||
@@ -205,6 +211,7 @@ class ComingSoonPage extends StatelessWidget {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package dev.lions.unionflow.server.entity;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntity;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entité Cotisation avec Lombok
|
||||||
|
* Représente une cotisation d'un membre à son organisation
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 1.0
|
||||||
|
* @since 2025-01-15
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "cotisations", indexes = {
|
||||||
|
@Index(name = "idx_cotisation_membre", columnList = "membre_id"),
|
||||||
|
@Index(name = "idx_cotisation_reference", columnList = "numero_reference", unique = true),
|
||||||
|
@Index(name = "idx_cotisation_statut", columnList = "statut"),
|
||||||
|
@Index(name = "idx_cotisation_echeance", columnList = "date_echeance"),
|
||||||
|
@Index(name = "idx_cotisation_type", columnList = "type_cotisation"),
|
||||||
|
@Index(name = "idx_cotisation_annee_mois", columnList = "annee, mois")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@EqualsAndHashCode(callSuper = false)
|
||||||
|
public class Cotisation extends PanacheEntity {
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "numero_reference", unique = true, nullable = false, length = 50)
|
||||||
|
private String numeroReference;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "membre_id", nullable = false)
|
||||||
|
private Membre membre;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "type_cotisation", nullable = false, length = 50)
|
||||||
|
private String typeCotisation;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@DecimalMin(value = "0.0", message = "Le montant dû doit être positif")
|
||||||
|
@Digits(integer = 10, fraction = 2)
|
||||||
|
@Column(name = "montant_du", nullable = false, precision = 12, scale = 2)
|
||||||
|
private BigDecimal montantDu;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@DecimalMin(value = "0.0", message = "Le montant payé doit être positif")
|
||||||
|
@Digits(integer = 10, fraction = 2)
|
||||||
|
@Column(name = "montant_paye", nullable = false, precision = 12, scale = 2)
|
||||||
|
private BigDecimal montantPaye = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "^[A-Z]{3}$", message = "Le code devise doit être un code ISO à 3 lettres")
|
||||||
|
@Column(name = "code_devise", nullable = false, length = 3)
|
||||||
|
private String codeDevise;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "^(EN_ATTENTE|PAYEE|EN_RETARD|PARTIELLEMENT_PAYEE|ANNULEE)$")
|
||||||
|
@Column(name = "statut", nullable = false, length = 30)
|
||||||
|
private String statut;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "date_echeance", nullable = false)
|
||||||
|
private LocalDate dateEcheance;
|
||||||
|
|
||||||
|
@Column(name = "date_paiement")
|
||||||
|
private LocalDateTime datePaiement;
|
||||||
|
|
||||||
|
@Size(max = 500)
|
||||||
|
@Column(name = "description", length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Size(max = 20)
|
||||||
|
@Column(name = "periode", length = 20)
|
||||||
|
private String periode;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Min(value = 2020, message = "L'année doit être supérieure à 2020")
|
||||||
|
@Max(value = 2100, message = "L'année doit être inférieure à 2100")
|
||||||
|
@Column(name = "annee", nullable = false)
|
||||||
|
private Integer annee;
|
||||||
|
|
||||||
|
@Min(value = 1, message = "Le mois doit être entre 1 et 12")
|
||||||
|
@Max(value = 12, message = "Le mois doit être entre 1 et 12")
|
||||||
|
@Column(name = "mois")
|
||||||
|
private Integer mois;
|
||||||
|
|
||||||
|
@Size(max = 1000)
|
||||||
|
@Column(name = "observations", length = 1000)
|
||||||
|
private String observations;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "recurrente", nullable = false)
|
||||||
|
private Boolean recurrente = false;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Min(value = 0, message = "Le nombre de rappels doit être positif")
|
||||||
|
@Column(name = "nombre_rappels", nullable = false)
|
||||||
|
private Integer nombreRappels = 0;
|
||||||
|
|
||||||
|
@Column(name = "date_dernier_rappel")
|
||||||
|
private LocalDateTime dateDernierRappel;
|
||||||
|
|
||||||
|
@Column(name = "valide_par_id")
|
||||||
|
private Long valideParId;
|
||||||
|
|
||||||
|
@Size(max = 100)
|
||||||
|
@Column(name = "nom_validateur", length = 100)
|
||||||
|
private String nomValidateur;
|
||||||
|
|
||||||
|
@Column(name = "date_validation")
|
||||||
|
private LocalDateTime dateValidation;
|
||||||
|
|
||||||
|
@Size(max = 50)
|
||||||
|
@Column(name = "methode_paiement", length = 50)
|
||||||
|
private String methodePaiement;
|
||||||
|
|
||||||
|
@Size(max = 100)
|
||||||
|
@Column(name = "reference_paiement", length = 100)
|
||||||
|
private String referencePaiement;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "date_creation", nullable = false)
|
||||||
|
private LocalDateTime dateCreation = LocalDateTime.now();
|
||||||
|
|
||||||
|
@Column(name = "date_modification")
|
||||||
|
private LocalDateTime dateModification;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Méthode métier pour calculer le montant restant à payer
|
||||||
|
*/
|
||||||
|
public BigDecimal getMontantRestant() {
|
||||||
|
if (montantDu == null || montantPaye == null) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
return montantDu.subtract(montantPaye);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Méthode métier pour vérifier si la cotisation est entièrement payée
|
||||||
|
*/
|
||||||
|
public boolean isEntierementPayee() {
|
||||||
|
return getMontantRestant().compareTo(BigDecimal.ZERO) <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Méthode métier pour vérifier si la cotisation est en retard
|
||||||
|
*/
|
||||||
|
public boolean isEnRetard() {
|
||||||
|
return dateEcheance != null &&
|
||||||
|
dateEcheance.isBefore(LocalDate.now()) &&
|
||||||
|
!isEntierementPayee();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Méthode métier pour générer un numéro de référence unique
|
||||||
|
*/
|
||||||
|
public static String genererNumeroReference() {
|
||||||
|
return "COT-" + LocalDate.now().getYear() + "-" +
|
||||||
|
String.format("%08d", System.currentTimeMillis() % 100000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback JPA avant la persistance
|
||||||
|
*/
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
if (numeroReference == null || numeroReference.isEmpty()) {
|
||||||
|
numeroReference = genererNumeroReference();
|
||||||
|
}
|
||||||
|
if (dateCreation == null) {
|
||||||
|
dateCreation = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
if (codeDevise == null) {
|
||||||
|
codeDevise = "XOF";
|
||||||
|
}
|
||||||
|
if (statut == null) {
|
||||||
|
statut = "EN_ATTENTE";
|
||||||
|
}
|
||||||
|
if (montantPaye == null) {
|
||||||
|
montantPaye = BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
if (nombreRappels == null) {
|
||||||
|
nombreRappels = 0;
|
||||||
|
}
|
||||||
|
if (recurrente == null) {
|
||||||
|
recurrente = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback JPA avant la mise à jour
|
||||||
|
*/
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
dateModification = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package dev.lions.unionflow.server.repository;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.entity.Cotisation;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
import io.quarkus.panache.common.Page;
|
||||||
|
import io.quarkus.panache.common.Sort;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository pour la gestion des cotisations
|
||||||
|
* Utilise Panache pour simplifier les opérations JPA
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 1.0
|
||||||
|
* @since 2025-01-15
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class CotisationRepository implements PanacheRepository<Cotisation> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve une cotisation par son numéro de référence
|
||||||
|
*
|
||||||
|
* @param numeroReference le numéro de référence unique
|
||||||
|
* @return Optional contenant la cotisation si trouvée
|
||||||
|
*/
|
||||||
|
public Optional<Cotisation> findByNumeroReference(String numeroReference) {
|
||||||
|
return find("numeroReference = ?1", numeroReference).firstResultOptional();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve toutes les cotisations d'un membre
|
||||||
|
*
|
||||||
|
* @param membreId l'identifiant du membre
|
||||||
|
* @param page pagination
|
||||||
|
* @param sort tri
|
||||||
|
* @return liste paginée des cotisations
|
||||||
|
*/
|
||||||
|
public List<Cotisation> findByMembreId(Long membreId, Page page, Sort sort) {
|
||||||
|
return find("membre.id = ?1", membreId)
|
||||||
|
.page(page)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve les cotisations par statut
|
||||||
|
*
|
||||||
|
* @param statut le statut recherché
|
||||||
|
* @param page pagination
|
||||||
|
* @return liste paginée des cotisations
|
||||||
|
*/
|
||||||
|
public List<Cotisation> findByStatut(String statut, Page page) {
|
||||||
|
return find("statut = ?1", Sort.by("dateEcheance").descending(), statut)
|
||||||
|
.page(page)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve les cotisations en retard
|
||||||
|
*
|
||||||
|
* @param dateReference date de référence (généralement aujourd'hui)
|
||||||
|
* @param page pagination
|
||||||
|
* @return liste des cotisations en retard
|
||||||
|
*/
|
||||||
|
public List<Cotisation> findCotisationsEnRetard(LocalDate dateReference, Page page) {
|
||||||
|
return find("dateEcheance < ?1 and statut != 'PAYEE' and statut != 'ANNULEE'",
|
||||||
|
Sort.by("dateEcheance").ascending(), dateReference)
|
||||||
|
.page(page)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve les cotisations par période (année/mois)
|
||||||
|
*
|
||||||
|
* @param annee l'année
|
||||||
|
* @param mois le mois (optionnel)
|
||||||
|
* @param page pagination
|
||||||
|
* @return liste des cotisations de la période
|
||||||
|
*/
|
||||||
|
public List<Cotisation> findByPeriode(Integer annee, Integer mois, Page page) {
|
||||||
|
if (mois != null) {
|
||||||
|
return find("annee = ?1 and mois = ?2", Sort.by("dateEcheance").descending(), annee, mois)
|
||||||
|
.page(page)
|
||||||
|
.list();
|
||||||
|
} else {
|
||||||
|
return find("annee = ?1", Sort.by("mois", "dateEcheance").descending(), annee)
|
||||||
|
.page(page)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve les cotisations par type
|
||||||
|
*
|
||||||
|
* @param typeCotisation le type de cotisation
|
||||||
|
* @param page pagination
|
||||||
|
* @return liste des cotisations du type spécifié
|
||||||
|
*/
|
||||||
|
public List<Cotisation> findByType(String typeCotisation, Page page) {
|
||||||
|
return find("typeCotisation = ?1", Sort.by("dateEcheance").descending(), typeCotisation)
|
||||||
|
.page(page)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche avancée avec filtres multiples
|
||||||
|
*
|
||||||
|
* @param membreId identifiant du membre (optionnel)
|
||||||
|
* @param statut statut (optionnel)
|
||||||
|
* @param typeCotisation type (optionnel)
|
||||||
|
* @param annee année (optionnel)
|
||||||
|
* @param mois mois (optionnel)
|
||||||
|
* @param page pagination
|
||||||
|
* @return liste filtrée des cotisations
|
||||||
|
*/
|
||||||
|
public List<Cotisation> rechercheAvancee(Long membreId, String statut, String typeCotisation,
|
||||||
|
Integer annee, Integer mois, Page page) {
|
||||||
|
StringBuilder query = new StringBuilder("1=1");
|
||||||
|
Map<String, Object> params = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
if (membreId != null) {
|
||||||
|
query.append(" and membre.id = :membreId");
|
||||||
|
params.put("membreId", membreId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statut != null && !statut.isEmpty()) {
|
||||||
|
query.append(" and statut = :statut");
|
||||||
|
params.put("statut", statut);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeCotisation != null && !typeCotisation.isEmpty()) {
|
||||||
|
query.append(" and typeCotisation = :typeCotisation");
|
||||||
|
params.put("typeCotisation", typeCotisation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (annee != null) {
|
||||||
|
query.append(" and annee = :annee");
|
||||||
|
params.put("annee", annee);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mois != null) {
|
||||||
|
query.append(" and mois = :mois");
|
||||||
|
params.put("mois", mois);
|
||||||
|
}
|
||||||
|
|
||||||
|
return find(query.toString(), Sort.by("dateEcheance").descending(), params)
|
||||||
|
.page(page)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le total des montants dus pour un membre
|
||||||
|
*
|
||||||
|
* @param membreId identifiant du membre
|
||||||
|
* @return montant total dû
|
||||||
|
*/
|
||||||
|
public BigDecimal calculerTotalMontantDu(Long membreId) {
|
||||||
|
return find("select sum(c.montantDu) from Cotisation c where c.membre.id = ?1", membreId)
|
||||||
|
.project(BigDecimal.class)
|
||||||
|
.firstResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le total des montants payés pour un membre
|
||||||
|
*
|
||||||
|
* @param membreId identifiant du membre
|
||||||
|
* @return montant total payé
|
||||||
|
*/
|
||||||
|
public BigDecimal calculerTotalMontantPaye(Long membreId) {
|
||||||
|
return find("select sum(c.montantPaye) from Cotisation c where c.membre.id = ?1", membreId)
|
||||||
|
.project(BigDecimal.class)
|
||||||
|
.firstResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte les cotisations par statut
|
||||||
|
*
|
||||||
|
* @param statut le statut
|
||||||
|
* @return nombre de cotisations
|
||||||
|
*/
|
||||||
|
public long compterParStatut(String statut) {
|
||||||
|
return count("statut = ?1", statut);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve les cotisations nécessitant un rappel
|
||||||
|
*
|
||||||
|
* @param joursAvantEcheance nombre de jours avant échéance
|
||||||
|
* @param nombreMaxRappels nombre maximum de rappels déjà envoyés
|
||||||
|
* @return liste des cotisations à rappeler
|
||||||
|
*/
|
||||||
|
public List<Cotisation> findCotisationsAuRappel(int joursAvantEcheance, int nombreMaxRappels) {
|
||||||
|
LocalDate dateRappel = LocalDate.now().plusDays(joursAvantEcheance);
|
||||||
|
return find("dateEcheance <= ?1 and statut != 'PAYEE' and statut != 'ANNULEE' and nombreRappels < ?2",
|
||||||
|
Sort.by("dateEcheance").ascending(), dateRappel, nombreMaxRappels)
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour le nombre de rappels pour une cotisation
|
||||||
|
*
|
||||||
|
* @param cotisationId identifiant de la cotisation
|
||||||
|
* @return nombre de lignes mises à jour
|
||||||
|
*/
|
||||||
|
public int incrementerNombreRappels(Long cotisationId) {
|
||||||
|
return update("nombreRappels = nombreRappels + 1, dateDernierRappel = ?1 where id = ?2",
|
||||||
|
LocalDateTime.now(), cotisationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistiques des cotisations par période
|
||||||
|
*
|
||||||
|
* @param annee l'année
|
||||||
|
* @param mois le mois (optionnel)
|
||||||
|
* @return map avec les statistiques
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getStatistiquesPeriode(Integer annee, Integer mois) {
|
||||||
|
String baseQuery = mois != null ?
|
||||||
|
"from Cotisation c where c.annee = ?1 and c.mois = ?2" :
|
||||||
|
"from Cotisation c where c.annee = ?1";
|
||||||
|
|
||||||
|
Object[] params = mois != null ? new Object[]{annee, mois} : new Object[]{annee};
|
||||||
|
|
||||||
|
Long totalCotisations = mois != null ?
|
||||||
|
count("annee = ?1 and mois = ?2", params) :
|
||||||
|
count("annee = ?1", params);
|
||||||
|
|
||||||
|
BigDecimal montantTotal = find("select sum(c.montantDu) " + baseQuery, params)
|
||||||
|
.project(BigDecimal.class)
|
||||||
|
.firstResult();
|
||||||
|
|
||||||
|
BigDecimal montantPaye = find("select sum(c.montantPaye) " + baseQuery, params)
|
||||||
|
.project(BigDecimal.class)
|
||||||
|
.firstResult();
|
||||||
|
|
||||||
|
Long cotisationsPayees = mois != null ?
|
||||||
|
count("annee = ?1 and mois = ?2 and statut = 'PAYEE'", annee, mois) :
|
||||||
|
count("annee = ?1 and statut = 'PAYEE'", annee);
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"totalCotisations", totalCotisations != null ? totalCotisations : 0L,
|
||||||
|
"montantTotal", montantTotal != null ? montantTotal : BigDecimal.ZERO,
|
||||||
|
"montantPaye", montantPaye != null ? montantPaye : BigDecimal.ZERO,
|
||||||
|
"cotisationsPayees", cotisationsPayees != null ? cotisationsPayees : 0L,
|
||||||
|
"tauxPaiement", totalCotisations != null && totalCotisations > 0 ?
|
||||||
|
(cotisationsPayees != null ? cotisationsPayees : 0L) * 100.0 / totalCotisations : 0.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ package dev.lions.unionflow.server.repository;
|
|||||||
|
|
||||||
import dev.lions.unionflow.server.entity.Membre;
|
import dev.lions.unionflow.server.entity.Membre;
|
||||||
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
import io.quarkus.panache.common.Page;
|
||||||
|
import io.quarkus.panache.common.Sort;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -45,7 +48,80 @@ public class MembreRepository implements PanacheRepository<Membre> {
|
|||||||
* Trouve les membres par nom ou prénom (recherche partielle)
|
* Trouve les membres par nom ou prénom (recherche partielle)
|
||||||
*/
|
*/
|
||||||
public List<Membre> findByNomOrPrenom(String recherche) {
|
public List<Membre> findByNomOrPrenom(String recherche) {
|
||||||
return find("lower(nom) like ?1 or lower(prenom) like ?1",
|
return find("lower(nom) like ?1 or lower(prenom) like ?1",
|
||||||
"%" + recherche.toLowerCase() + "%").list();
|
"%" + recherche.toLowerCase() + "%").list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve tous les membres actifs avec pagination et tri
|
||||||
|
*/
|
||||||
|
public List<Membre> findAllActifs(Page page, Sort sort) {
|
||||||
|
return find("actif", sort, true).page(page).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve les membres par nom ou prénom avec pagination et tri
|
||||||
|
*/
|
||||||
|
public List<Membre> findByNomOrPrenom(String recherche, Page page, Sort sort) {
|
||||||
|
return find("lower(nom) like ?1 or lower(prenom) like ?1",
|
||||||
|
sort, "%" + recherche.toLowerCase() + "%")
|
||||||
|
.page(page).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte les nouveaux membres depuis une date donnée
|
||||||
|
*/
|
||||||
|
public long countNouveauxMembres(LocalDate depuis) {
|
||||||
|
return count("dateAdhesion >= ?1", depuis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve les membres par statut avec pagination
|
||||||
|
*/
|
||||||
|
public List<Membre> findByStatut(boolean actif, Page page, Sort sort) {
|
||||||
|
return find("actif", sort, actif).page(page).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve les membres par tranche d'âge
|
||||||
|
*/
|
||||||
|
public List<Membre> findByTrancheAge(int ageMin, int ageMax, Page page, Sort sort) {
|
||||||
|
LocalDate dateNaissanceMax = LocalDate.now().minusYears(ageMin);
|
||||||
|
LocalDate dateNaissanceMin = LocalDate.now().minusYears(ageMax + 1);
|
||||||
|
|
||||||
|
return find("dateNaissance between ?1 and ?2", sort, dateNaissanceMin, dateNaissanceMax)
|
||||||
|
.page(page).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche avancée avec filtres multiples
|
||||||
|
*/
|
||||||
|
public List<Membre> rechercheAvancee(String recherche, Boolean actif,
|
||||||
|
LocalDate dateAdhesionMin, LocalDate dateAdhesionMax,
|
||||||
|
Page page, Sort sort) {
|
||||||
|
StringBuilder query = new StringBuilder("1=1");
|
||||||
|
java.util.Map<String, Object> params = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
if (recherche != null && !recherche.trim().isEmpty()) {
|
||||||
|
query.append(" and (lower(nom) like :recherche or lower(prenom) like :recherche or lower(email) like :recherche)");
|
||||||
|
params.put("recherche", "%" + recherche.toLowerCase() + "%");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actif != null) {
|
||||||
|
query.append(" and actif = :actif");
|
||||||
|
params.put("actif", actif);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateAdhesionMin != null) {
|
||||||
|
query.append(" and dateAdhesion >= :dateAdhesionMin");
|
||||||
|
params.put("dateAdhesionMin", dateAdhesionMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateAdhesionMax != null) {
|
||||||
|
query.append(" and dateAdhesion <= :dateAdhesionMax");
|
||||||
|
params.put("dateAdhesionMax", dateAdhesionMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
return find(query.toString(), sort, params).page(page).list();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
package dev.lions.unionflow.server.resource;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.dto.finance.CotisationDTO;
|
||||||
|
import dev.lions.unionflow.server.service.CotisationService;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.ws.rs.*;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource REST pour la gestion des cotisations
|
||||||
|
* Expose les endpoints API pour les opérations CRUD sur les cotisations
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 1.0
|
||||||
|
* @since 2025-01-15
|
||||||
|
*/
|
||||||
|
@Path("/api/cotisations")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Tag(name = "Cotisations", description = "Gestion des cotisations des membres")
|
||||||
|
@Slf4j
|
||||||
|
public class CotisationResource {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
CotisationService cotisationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère toutes les cotisations avec pagination
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Operation(summary = "Lister toutes les cotisations",
|
||||||
|
description = "Récupère la liste paginée de toutes les cotisations")
|
||||||
|
@APIResponses({
|
||||||
|
@APIResponse(responseCode = "200", description = "Liste des cotisations récupérée avec succès",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = CotisationDTO.class))),
|
||||||
|
@APIResponse(responseCode = "400", description = "Paramètres de pagination invalides"),
|
||||||
|
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
|
||||||
|
})
|
||||||
|
public Response getAllCotisations(
|
||||||
|
@Parameter(description = "Numéro de page (0-based)", example = "0")
|
||||||
|
@QueryParam("page") @DefaultValue("0") @Min(0) int page,
|
||||||
|
|
||||||
|
@Parameter(description = "Taille de la page", example = "20")
|
||||||
|
@QueryParam("size") @DefaultValue("20") @Min(1) int size) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("GET /api/cotisations - page: {}, size: {}", page, size);
|
||||||
|
|
||||||
|
List<CotisationDTO> cotisations = cotisationService.getAllCotisations(page, size);
|
||||||
|
|
||||||
|
log.info("Récupération réussie de {} cotisations", cotisations.size());
|
||||||
|
return Response.ok(cotisations).build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Erreur lors de la récupération des cotisations", e);
|
||||||
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||||
|
.entity(Map.of("error", "Erreur lors de la récupération des cotisations",
|
||||||
|
"message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère une cotisation par son ID
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/{id}")
|
||||||
|
@Operation(summary = "Récupérer une cotisation par ID",
|
||||||
|
description = "Récupère les détails d'une cotisation spécifique")
|
||||||
|
@APIResponses({
|
||||||
|
@APIResponse(responseCode = "200", description = "Cotisation trouvée",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = CotisationDTO.class))),
|
||||||
|
@APIResponse(responseCode = "404", description = "Cotisation non trouvée"),
|
||||||
|
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
|
||||||
|
})
|
||||||
|
public Response getCotisationById(
|
||||||
|
@Parameter(description = "Identifiant de la cotisation", required = true)
|
||||||
|
@PathParam("id") @NotNull Long id) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("GET /api/cotisations/{}", id);
|
||||||
|
|
||||||
|
CotisationDTO cotisation = cotisationService.getCotisationById(id);
|
||||||
|
|
||||||
|
log.info("Cotisation récupérée avec succès - ID: {}", id);
|
||||||
|
return Response.ok(cotisation).build();
|
||||||
|
|
||||||
|
} catch (NotFoundException e) {
|
||||||
|
log.warn("Cotisation non trouvée - ID: {}", id);
|
||||||
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
|
.entity(Map.of("error", "Cotisation non trouvée", "id", id))
|
||||||
|
.build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Erreur lors de la récupération de la cotisation - ID: " + id, e);
|
||||||
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||||
|
.entity(Map.of("error", "Erreur lors de la récupération de la cotisation",
|
||||||
|
"message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère une cotisation par son numéro de référence
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/reference/{numeroReference}")
|
||||||
|
@Operation(summary = "Récupérer une cotisation par référence",
|
||||||
|
description = "Récupère une cotisation par son numéro de référence unique")
|
||||||
|
@APIResponses({
|
||||||
|
@APIResponse(responseCode = "200", description = "Cotisation trouvée"),
|
||||||
|
@APIResponse(responseCode = "404", description = "Cotisation non trouvée"),
|
||||||
|
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
|
||||||
|
})
|
||||||
|
public Response getCotisationByReference(
|
||||||
|
@Parameter(description = "Numéro de référence de la cotisation", required = true)
|
||||||
|
@PathParam("numeroReference") @NotNull String numeroReference) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("GET /api/cotisations/reference/{}", numeroReference);
|
||||||
|
|
||||||
|
CotisationDTO cotisation = cotisationService.getCotisationByReference(numeroReference);
|
||||||
|
|
||||||
|
log.info("Cotisation récupérée avec succès - Référence: {}", numeroReference);
|
||||||
|
return Response.ok(cotisation).build();
|
||||||
|
|
||||||
|
} catch (NotFoundException e) {
|
||||||
|
log.warn("Cotisation non trouvée - Référence: {}", numeroReference);
|
||||||
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
|
.entity(Map.of("error", "Cotisation non trouvée", "reference", numeroReference))
|
||||||
|
.build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Erreur lors de la récupération de la cotisation - Référence: " + numeroReference, e);
|
||||||
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||||
|
.entity(Map.of("error", "Erreur lors de la récupération de la cotisation",
|
||||||
|
"message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une nouvelle cotisation
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Operation(summary = "Créer une nouvelle cotisation",
|
||||||
|
description = "Crée une nouvelle cotisation pour un membre")
|
||||||
|
@APIResponses({
|
||||||
|
@APIResponse(responseCode = "201", description = "Cotisation créée avec succès",
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = CotisationDTO.class))),
|
||||||
|
@APIResponse(responseCode = "400", description = "Données invalides"),
|
||||||
|
@APIResponse(responseCode = "404", description = "Membre non trouvé"),
|
||||||
|
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
|
||||||
|
})
|
||||||
|
public Response createCotisation(
|
||||||
|
@Parameter(description = "Données de la cotisation à créer", required = true)
|
||||||
|
@Valid CotisationDTO cotisationDTO) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("POST /api/cotisations - Création cotisation pour membre: {}",
|
||||||
|
cotisationDTO.getMembreId());
|
||||||
|
|
||||||
|
CotisationDTO nouvelleCotisation = cotisationService.createCotisation(cotisationDTO);
|
||||||
|
|
||||||
|
log.info("Cotisation créée avec succès - ID: {}, Référence: {}",
|
||||||
|
nouvelleCotisation.getId(), nouvelleCotisation.getNumeroReference());
|
||||||
|
|
||||||
|
return Response.status(Response.Status.CREATED)
|
||||||
|
.entity(nouvelleCotisation)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
} catch (NotFoundException e) {
|
||||||
|
log.warn("Membre non trouvé lors de la création de cotisation: {}", cotisationDTO.getMembreId());
|
||||||
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
|
.entity(Map.of("error", "Membre non trouvé", "membreId", cotisationDTO.getMembreId()))
|
||||||
|
.build();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.warn("Données invalides pour la création de cotisation: {}", e.getMessage());
|
||||||
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
|
.entity(Map.of("error", "Données invalides", "message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Erreur lors de la création de la cotisation", e);
|
||||||
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||||
|
.entity(Map.of("error", "Erreur lors de la création de la cotisation",
|
||||||
|
"message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour une cotisation existante
|
||||||
|
*/
|
||||||
|
@PUT
|
||||||
|
@Path("/{id}")
|
||||||
|
@Operation(summary = "Mettre à jour une cotisation",
|
||||||
|
description = "Met à jour les données d'une cotisation existante")
|
||||||
|
@APIResponses({
|
||||||
|
@APIResponse(responseCode = "200", description = "Cotisation mise à jour avec succès"),
|
||||||
|
@APIResponse(responseCode = "400", description = "Données invalides"),
|
||||||
|
@APIResponse(responseCode = "404", description = "Cotisation non trouvée"),
|
||||||
|
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
|
||||||
|
})
|
||||||
|
public Response updateCotisation(
|
||||||
|
@Parameter(description = "Identifiant de la cotisation", required = true)
|
||||||
|
@PathParam("id") @NotNull Long id,
|
||||||
|
|
||||||
|
@Parameter(description = "Nouvelles données de la cotisation", required = true)
|
||||||
|
@Valid CotisationDTO cotisationDTO) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("PUT /api/cotisations/{}", id);
|
||||||
|
|
||||||
|
CotisationDTO cotisationMiseAJour = cotisationService.updateCotisation(id, cotisationDTO);
|
||||||
|
|
||||||
|
log.info("Cotisation mise à jour avec succès - ID: {}", id);
|
||||||
|
return Response.ok(cotisationMiseAJour).build();
|
||||||
|
|
||||||
|
} catch (NotFoundException e) {
|
||||||
|
log.warn("Cotisation non trouvée pour mise à jour - ID: {}", id);
|
||||||
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
|
.entity(Map.of("error", "Cotisation non trouvée", "id", id))
|
||||||
|
.build();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.warn("Données invalides pour la mise à jour de cotisation - ID: {}, Erreur: {}", id, e.getMessage());
|
||||||
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
|
.entity(Map.of("error", "Données invalides", "message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Erreur lors de la mise à jour de la cotisation - ID: " + id, e);
|
||||||
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||||
|
.entity(Map.of("error", "Erreur lors de la mise à jour de la cotisation",
|
||||||
|
"message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une cotisation
|
||||||
|
*/
|
||||||
|
@DELETE
|
||||||
|
@Path("/{id}")
|
||||||
|
@Operation(summary = "Supprimer une cotisation",
|
||||||
|
description = "Supprime (désactive) une cotisation")
|
||||||
|
@APIResponses({
|
||||||
|
@APIResponse(responseCode = "204", description = "Cotisation supprimée avec succès"),
|
||||||
|
@APIResponse(responseCode = "404", description = "Cotisation non trouvée"),
|
||||||
|
@APIResponse(responseCode = "409", description = "Impossible de supprimer une cotisation payée"),
|
||||||
|
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
|
||||||
|
})
|
||||||
|
public Response deleteCotisation(
|
||||||
|
@Parameter(description = "Identifiant de la cotisation", required = true)
|
||||||
|
@PathParam("id") @NotNull Long id) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("DELETE /api/cotisations/{}", id);
|
||||||
|
|
||||||
|
cotisationService.deleteCotisation(id);
|
||||||
|
|
||||||
|
log.info("Cotisation supprimée avec succès - ID: {}", id);
|
||||||
|
return Response.noContent().build();
|
||||||
|
|
||||||
|
} catch (NotFoundException e) {
|
||||||
|
log.warn("Cotisation non trouvée pour suppression - ID: {}", id);
|
||||||
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
|
.entity(Map.of("error", "Cotisation non trouvée", "id", id))
|
||||||
|
.build();
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
log.warn("Impossible de supprimer la cotisation - ID: {}, Raison: {}", id, e.getMessage());
|
||||||
|
return Response.status(Response.Status.CONFLICT)
|
||||||
|
.entity(Map.of("error", "Impossible de supprimer la cotisation", "message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Erreur lors de la suppression de la cotisation - ID: " + id, e);
|
||||||
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||||
|
.entity(Map.of("error", "Erreur lors de la suppression de la cotisation",
|
||||||
|
"message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les cotisations d'un membre
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/membre/{membreId}")
|
||||||
|
@Operation(summary = "Lister les cotisations d'un membre",
|
||||||
|
description = "Récupère toutes les cotisations d'un membre spécifique")
|
||||||
|
@APIResponses({
|
||||||
|
@APIResponse(responseCode = "200", description = "Liste des cotisations du membre"),
|
||||||
|
@APIResponse(responseCode = "404", description = "Membre non trouvé"),
|
||||||
|
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
|
||||||
|
})
|
||||||
|
public Response getCotisationsByMembre(
|
||||||
|
@Parameter(description = "Identifiant du membre", required = true)
|
||||||
|
@PathParam("membreId") @NotNull Long membreId,
|
||||||
|
|
||||||
|
@Parameter(description = "Numéro de page", example = "0")
|
||||||
|
@QueryParam("page") @DefaultValue("0") @Min(0) int page,
|
||||||
|
|
||||||
|
@Parameter(description = "Taille de la page", example = "20")
|
||||||
|
@QueryParam("size") @DefaultValue("20") @Min(1) int size) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("GET /api/cotisations/membre/{} - page: {}, size: {}", membreId, page, size);
|
||||||
|
|
||||||
|
List<CotisationDTO> cotisations = cotisationService.getCotisationsByMembre(membreId, page, size);
|
||||||
|
|
||||||
|
log.info("Récupération réussie de {} cotisations pour le membre {}", cotisations.size(), membreId);
|
||||||
|
return Response.ok(cotisations).build();
|
||||||
|
|
||||||
|
} catch (NotFoundException e) {
|
||||||
|
log.warn("Membre non trouvé - ID: {}", membreId);
|
||||||
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
|
.entity(Map.of("error", "Membre non trouvé", "membreId", membreId))
|
||||||
|
.build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Erreur lors de la récupération des cotisations du membre - ID: " + membreId, e);
|
||||||
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||||
|
.entity(Map.of("error", "Erreur lors de la récupération des cotisations",
|
||||||
|
"message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les cotisations par statut
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/statut/{statut}")
|
||||||
|
@Operation(summary = "Lister les cotisations par statut",
|
||||||
|
description = "Récupère toutes les cotisations ayant un statut spécifique")
|
||||||
|
@APIResponses({
|
||||||
|
@APIResponse(responseCode = "200", description = "Liste des cotisations avec le statut spécifié"),
|
||||||
|
@APIResponse(responseCode = "400", description = "Statut invalide"),
|
||||||
|
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
|
||||||
|
})
|
||||||
|
public Response getCotisationsByStatut(
|
||||||
|
@Parameter(description = "Statut des cotisations", required = true,
|
||||||
|
example = "EN_ATTENTE")
|
||||||
|
@PathParam("statut") @NotNull String statut,
|
||||||
|
|
||||||
|
@Parameter(description = "Numéro de page", example = "0")
|
||||||
|
@QueryParam("page") @DefaultValue("0") @Min(0) int page,
|
||||||
|
|
||||||
|
@Parameter(description = "Taille de la page", example = "20")
|
||||||
|
@QueryParam("size") @DefaultValue("20") @Min(1) int size) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("GET /api/cotisations/statut/{} - page: {}, size: {}", statut, page, size);
|
||||||
|
|
||||||
|
List<CotisationDTO> cotisations = cotisationService.getCotisationsByStatut(statut, page, size);
|
||||||
|
|
||||||
|
log.info("Récupération réussie de {} cotisations avec statut {}", cotisations.size(), statut);
|
||||||
|
return Response.ok(cotisations).build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Erreur lors de la récupération des cotisations par statut - Statut: " + statut, e);
|
||||||
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||||
|
.entity(Map.of("error", "Erreur lors de la récupération des cotisations",
|
||||||
|
"message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les cotisations en retard
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/en-retard")
|
||||||
|
@Operation(summary = "Lister les cotisations en retard",
|
||||||
|
description = "Récupère toutes les cotisations dont la date d'échéance est dépassée")
|
||||||
|
@APIResponses({
|
||||||
|
@APIResponse(responseCode = "200", description = "Liste des cotisations en retard"),
|
||||||
|
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
|
||||||
|
})
|
||||||
|
public Response getCotisationsEnRetard(
|
||||||
|
@Parameter(description = "Numéro de page", example = "0")
|
||||||
|
@QueryParam("page") @DefaultValue("0") @Min(0) int page,
|
||||||
|
|
||||||
|
@Parameter(description = "Taille de la page", example = "20")
|
||||||
|
@QueryParam("size") @DefaultValue("20") @Min(1) int size) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("GET /api/cotisations/en-retard - page: {}, size: {}", page, size);
|
||||||
|
|
||||||
|
List<CotisationDTO> cotisations = cotisationService.getCotisationsEnRetard(page, size);
|
||||||
|
|
||||||
|
log.info("Récupération réussie de {} cotisations en retard", cotisations.size());
|
||||||
|
return Response.ok(cotisations).build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Erreur lors de la récupération des cotisations en retard", e);
|
||||||
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||||
|
.entity(Map.of("error", "Erreur lors de la récupération des cotisations en retard",
|
||||||
|
"message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche avancée de cotisations
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/recherche")
|
||||||
|
@Operation(summary = "Recherche avancée de cotisations",
|
||||||
|
description = "Recherche de cotisations avec filtres multiples")
|
||||||
|
@APIResponses({
|
||||||
|
@APIResponse(responseCode = "200", description = "Résultats de la recherche"),
|
||||||
|
@APIResponse(responseCode = "400", description = "Paramètres de recherche invalides"),
|
||||||
|
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
|
||||||
|
})
|
||||||
|
public Response rechercherCotisations(
|
||||||
|
@Parameter(description = "Identifiant du membre")
|
||||||
|
@QueryParam("membreId") Long membreId,
|
||||||
|
|
||||||
|
@Parameter(description = "Statut de la cotisation")
|
||||||
|
@QueryParam("statut") String statut,
|
||||||
|
|
||||||
|
@Parameter(description = "Type de cotisation")
|
||||||
|
@QueryParam("typeCotisation") String typeCotisation,
|
||||||
|
|
||||||
|
@Parameter(description = "Année")
|
||||||
|
@QueryParam("annee") Integer annee,
|
||||||
|
|
||||||
|
@Parameter(description = "Mois")
|
||||||
|
@QueryParam("mois") Integer mois,
|
||||||
|
|
||||||
|
@Parameter(description = "Numéro de page", example = "0")
|
||||||
|
@QueryParam("page") @DefaultValue("0") @Min(0) int page,
|
||||||
|
|
||||||
|
@Parameter(description = "Taille de la page", example = "20")
|
||||||
|
@QueryParam("size") @DefaultValue("20") @Min(1) int size) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("GET /api/cotisations/recherche - Filtres: membreId={}, statut={}, type={}, annee={}, mois={}",
|
||||||
|
membreId, statut, typeCotisation, annee, mois);
|
||||||
|
|
||||||
|
List<CotisationDTO> cotisations = cotisationService.rechercherCotisations(
|
||||||
|
membreId, statut, typeCotisation, annee, mois, page, size);
|
||||||
|
|
||||||
|
log.info("Recherche réussie - {} cotisations trouvées", cotisations.size());
|
||||||
|
return Response.ok(cotisations).build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Erreur lors de la recherche de cotisations", e);
|
||||||
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||||
|
.entity(Map.of("error", "Erreur lors de la recherche de cotisations",
|
||||||
|
"message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les statistiques des cotisations
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/stats")
|
||||||
|
@Operation(summary = "Statistiques des cotisations",
|
||||||
|
description = "Récupère les statistiques globales des cotisations")
|
||||||
|
@APIResponses({
|
||||||
|
@APIResponse(responseCode = "200", description = "Statistiques récupérées avec succès"),
|
||||||
|
@APIResponse(responseCode = "500", description = "Erreur interne du serveur")
|
||||||
|
})
|
||||||
|
public Response getStatistiquesCotisations() {
|
||||||
|
try {
|
||||||
|
log.info("GET /api/cotisations/stats");
|
||||||
|
|
||||||
|
Map<String, Object> statistiques = cotisationService.getStatistiquesCotisations();
|
||||||
|
|
||||||
|
log.info("Statistiques récupérées avec succès");
|
||||||
|
return Response.ok(statistiques).build();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Erreur lors de la récupération des statistiques", e);
|
||||||
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||||
|
.entity(Map.of("error", "Erreur lors de la récupération des statistiques",
|
||||||
|
"message", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package dev.lions.unionflow.server.resource;
|
package dev.lions.unionflow.server.resource;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.dto.membre.MembreDTO;
|
||||||
import dev.lions.unionflow.server.entity.Membre;
|
import dev.lions.unionflow.server.entity.Membre;
|
||||||
import dev.lions.unionflow.server.service.MembreService;
|
import dev.lions.unionflow.server.service.MembreService;
|
||||||
|
import io.quarkus.panache.common.Page;
|
||||||
|
import io.quarkus.panache.common.Sort;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -35,10 +38,21 @@ public class MembreResource {
|
|||||||
@GET
|
@GET
|
||||||
@Operation(summary = "Lister tous les membres actifs")
|
@Operation(summary = "Lister tous les membres actifs")
|
||||||
@APIResponse(responseCode = "200", description = "Liste des membres actifs")
|
@APIResponse(responseCode = "200", description = "Liste des membres actifs")
|
||||||
public Response listerMembres() {
|
public Response listerMembres(
|
||||||
LOG.info("Récupération de la liste des membres actifs");
|
@Parameter(description = "Numéro de page (0-based)") @QueryParam("page") @DefaultValue("0") int page,
|
||||||
List<Membre> membres = membreService.listerMembresActifs();
|
@Parameter(description = "Taille de la page") @QueryParam("size") @DefaultValue("20") int size,
|
||||||
return Response.ok(membres).build();
|
@Parameter(description = "Champ de tri") @QueryParam("sort") @DefaultValue("nom") String sortField,
|
||||||
|
@Parameter(description = "Direction du tri (asc/desc)") @QueryParam("direction") @DefaultValue("asc") String sortDirection) {
|
||||||
|
|
||||||
|
LOG.infof("Récupération de la liste des membres actifs - page: %d, size: %d", page, size);
|
||||||
|
|
||||||
|
Sort sort = "desc".equalsIgnoreCase(sortDirection) ?
|
||||||
|
Sort.by(sortField).descending() : Sort.by(sortField).ascending();
|
||||||
|
|
||||||
|
List<Membre> membres = membreService.listerMembresActifs(Page.of(page, size), sort);
|
||||||
|
List<MembreDTO> membresDTO = membreService.convertToDTOList(membres);
|
||||||
|
|
||||||
|
return Response.ok(membresDTO).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -49,7 +63,10 @@ public class MembreResource {
|
|||||||
public Response obtenirMembre(@Parameter(description = "ID du membre") @PathParam("id") Long id) {
|
public Response obtenirMembre(@Parameter(description = "ID du membre") @PathParam("id") Long id) {
|
||||||
LOG.infof("Récupération du membre ID: %d", id);
|
LOG.infof("Récupération du membre ID: %d", id);
|
||||||
return membreService.trouverParId(id)
|
return membreService.trouverParId(id)
|
||||||
.map(membre -> Response.ok(membre).build())
|
.map(membre -> {
|
||||||
|
MembreDTO membreDTO = membreService.convertToDTO(membre);
|
||||||
|
return Response.ok(membreDTO).build();
|
||||||
|
})
|
||||||
.orElse(Response.status(Response.Status.NOT_FOUND)
|
.orElse(Response.status(Response.Status.NOT_FOUND)
|
||||||
.entity(Map.of("message", "Membre non trouvé")).build());
|
.entity(Map.of("message", "Membre non trouvé")).build());
|
||||||
}
|
}
|
||||||
@@ -58,11 +75,25 @@ public class MembreResource {
|
|||||||
@Operation(summary = "Créer un nouveau membre")
|
@Operation(summary = "Créer un nouveau membre")
|
||||||
@APIResponse(responseCode = "201", description = "Membre créé avec succès")
|
@APIResponse(responseCode = "201", description = "Membre créé avec succès")
|
||||||
@APIResponse(responseCode = "400", description = "Données invalides")
|
@APIResponse(responseCode = "400", description = "Données invalides")
|
||||||
public Response creerMembre(@Valid Membre membre) {
|
public Response creerMembre(@Valid MembreDTO membreDTO) {
|
||||||
LOG.infof("Création d'un nouveau membre: %s", membre.getEmail());
|
LOG.infof("Création d'un nouveau membre: %s", membreDTO.getEmail());
|
||||||
try {
|
try {
|
||||||
|
// Validation des données DTO
|
||||||
|
if (!membreDTO.isDataValid()) {
|
||||||
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
|
.entity(Map.of("message", "Données du membre invalides")).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversion DTO vers entité
|
||||||
|
Membre membre = membreService.convertFromDTO(membreDTO);
|
||||||
|
|
||||||
|
// Création du membre
|
||||||
Membre nouveauMembre = membreService.creerMembre(membre);
|
Membre nouveauMembre = membreService.creerMembre(membre);
|
||||||
return Response.status(Response.Status.CREATED).entity(nouveauMembre).build();
|
|
||||||
|
// Conversion de retour vers DTO
|
||||||
|
MembreDTO nouveauMembreDTO = membreService.convertToDTO(nouveauMembre);
|
||||||
|
|
||||||
|
return Response.status(Response.Status.CREATED).entity(nouveauMembreDTO).build();
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return Response.status(Response.Status.BAD_REQUEST)
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
.entity(Map.of("message", e.getMessage())).build();
|
.entity(Map.of("message", e.getMessage())).build();
|
||||||
@@ -76,11 +107,25 @@ public class MembreResource {
|
|||||||
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
@APIResponse(responseCode = "404", description = "Membre non trouvé")
|
||||||
@APIResponse(responseCode = "400", description = "Données invalides")
|
@APIResponse(responseCode = "400", description = "Données invalides")
|
||||||
public Response mettreAJourMembre(@Parameter(description = "ID du membre") @PathParam("id") Long id,
|
public Response mettreAJourMembre(@Parameter(description = "ID du membre") @PathParam("id") Long id,
|
||||||
@Valid Membre membre) {
|
@Valid MembreDTO membreDTO) {
|
||||||
LOG.infof("Mise à jour du membre ID: %d", id);
|
LOG.infof("Mise à jour du membre ID: %d", id);
|
||||||
try {
|
try {
|
||||||
|
// Validation des données DTO
|
||||||
|
if (!membreDTO.isDataValid()) {
|
||||||
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
|
.entity(Map.of("message", "Données du membre invalides")).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversion DTO vers entité
|
||||||
|
Membre membre = membreService.convertFromDTO(membreDTO);
|
||||||
|
|
||||||
|
// Mise à jour du membre
|
||||||
Membre membreMisAJour = membreService.mettreAJourMembre(id, membre);
|
Membre membreMisAJour = membreService.mettreAJourMembre(id, membre);
|
||||||
return Response.ok(membreMisAJour).build();
|
|
||||||
|
// Conversion de retour vers DTO
|
||||||
|
MembreDTO membreMisAJourDTO = membreService.convertToDTO(membreMisAJour);
|
||||||
|
|
||||||
|
return Response.ok(membreMisAJourDTO).build();
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return Response.status(Response.Status.BAD_REQUEST)
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
.entity(Map.of("message", e.getMessage())).build();
|
.entity(Map.of("message", e.getMessage())).build();
|
||||||
@@ -107,26 +152,74 @@ public class MembreResource {
|
|||||||
@Path("/recherche")
|
@Path("/recherche")
|
||||||
@Operation(summary = "Rechercher des membres par nom ou prénom")
|
@Operation(summary = "Rechercher des membres par nom ou prénom")
|
||||||
@APIResponse(responseCode = "200", description = "Résultats de la recherche")
|
@APIResponse(responseCode = "200", description = "Résultats de la recherche")
|
||||||
public Response rechercherMembres(@Parameter(description = "Terme de recherche") @QueryParam("q") String recherche) {
|
public Response rechercherMembres(
|
||||||
|
@Parameter(description = "Terme de recherche") @QueryParam("q") String recherche,
|
||||||
|
@Parameter(description = "Numéro de page (0-based)") @QueryParam("page") @DefaultValue("0") int page,
|
||||||
|
@Parameter(description = "Taille de la page") @QueryParam("size") @DefaultValue("20") int size,
|
||||||
|
@Parameter(description = "Champ de tri") @QueryParam("sort") @DefaultValue("nom") String sortField,
|
||||||
|
@Parameter(description = "Direction du tri (asc/desc)") @QueryParam("direction") @DefaultValue("asc") String sortDirection) {
|
||||||
|
|
||||||
LOG.infof("Recherche de membres avec le terme: %s", recherche);
|
LOG.infof("Recherche de membres avec le terme: %s", recherche);
|
||||||
if (recherche == null || recherche.trim().isEmpty()) {
|
if (recherche == null || recherche.trim().isEmpty()) {
|
||||||
return Response.status(Response.Status.BAD_REQUEST)
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
.entity(Map.of("message", "Le terme de recherche est requis")).build();
|
.entity(Map.of("message", "Le terme de recherche est requis")).build();
|
||||||
}
|
}
|
||||||
List<Membre> membres = membreService.rechercherMembres(recherche.trim());
|
|
||||||
return Response.ok(membres).build();
|
Sort sort = "desc".equalsIgnoreCase(sortDirection) ?
|
||||||
|
Sort.by(sortField).descending() : Sort.by(sortField).ascending();
|
||||||
|
|
||||||
|
List<Membre> membres = membreService.rechercherMembres(recherche.trim(), Page.of(page, size), sort);
|
||||||
|
List<MembreDTO> membresDTO = membreService.convertToDTOList(membres);
|
||||||
|
|
||||||
|
return Response.ok(membresDTO).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/stats")
|
@Path("/stats")
|
||||||
@Operation(summary = "Obtenir les statistiques des membres")
|
@Operation(summary = "Obtenir les statistiques avancées des membres")
|
||||||
@APIResponse(responseCode = "200", description = "Statistiques des membres")
|
@APIResponse(responseCode = "200", description = "Statistiques complètes des membres")
|
||||||
public Response obtenirStatistiques() {
|
public Response obtenirStatistiques() {
|
||||||
LOG.info("Récupération des statistiques des membres");
|
LOG.info("Récupération des statistiques avancées des membres");
|
||||||
long nombreMembresActifs = membreService.compterMembresActifs();
|
Map<String, Object> statistiques = membreService.obtenirStatistiquesAvancees();
|
||||||
return Response.ok(Map.of(
|
return Response.ok(statistiques).build();
|
||||||
"nombreMembresActifs", nombreMembresActifs,
|
}
|
||||||
"timestamp", java.time.LocalDateTime.now()
|
|
||||||
)).build();
|
@GET
|
||||||
|
@Path("/recherche-avancee")
|
||||||
|
@Operation(summary = "Recherche avancée de membres avec filtres multiples")
|
||||||
|
@APIResponse(responseCode = "200", description = "Résultats de la recherche avancée")
|
||||||
|
public Response rechercheAvancee(
|
||||||
|
@Parameter(description = "Terme de recherche") @QueryParam("q") String recherche,
|
||||||
|
@Parameter(description = "Statut actif (true/false)") @QueryParam("actif") Boolean actif,
|
||||||
|
@Parameter(description = "Date d'adhésion minimum (YYYY-MM-DD)") @QueryParam("dateAdhesionMin") String dateAdhesionMin,
|
||||||
|
@Parameter(description = "Date d'adhésion maximum (YYYY-MM-DD)") @QueryParam("dateAdhesionMax") String dateAdhesionMax,
|
||||||
|
@Parameter(description = "Numéro de page (0-based)") @QueryParam("page") @DefaultValue("0") int page,
|
||||||
|
@Parameter(description = "Taille de la page") @QueryParam("size") @DefaultValue("20") int size,
|
||||||
|
@Parameter(description = "Champ de tri") @QueryParam("sort") @DefaultValue("nom") String sortField,
|
||||||
|
@Parameter(description = "Direction du tri (asc/desc)") @QueryParam("direction") @DefaultValue("asc") String sortDirection) {
|
||||||
|
|
||||||
|
LOG.infof("Recherche avancée de membres - recherche: %s, actif: %s", recherche, actif);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Sort sort = "desc".equalsIgnoreCase(sortDirection) ?
|
||||||
|
Sort.by(sortField).descending() : Sort.by(sortField).ascending();
|
||||||
|
|
||||||
|
// Conversion des dates si fournies
|
||||||
|
java.time.LocalDate dateMin = dateAdhesionMin != null ?
|
||||||
|
java.time.LocalDate.parse(dateAdhesionMin) : null;
|
||||||
|
java.time.LocalDate dateMax = dateAdhesionMax != null ?
|
||||||
|
java.time.LocalDate.parse(dateAdhesionMax) : null;
|
||||||
|
|
||||||
|
List<Membre> membres = membreService.rechercheAvancee(
|
||||||
|
recherche, actif, dateMin, dateMax, Page.of(page, size), sort);
|
||||||
|
List<MembreDTO> membresDTO = membreService.convertToDTOList(membres);
|
||||||
|
|
||||||
|
return Response.ok(membresDTO).build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.errorf("Erreur lors de la recherche avancée: %s", e.getMessage());
|
||||||
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
|
.entity(Map.of("message", "Erreur dans les paramètres de recherche: " + e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
package dev.lions.unionflow.server.service;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.dto.finance.CotisationDTO;
|
||||||
|
import dev.lions.unionflow.server.entity.Cotisation;
|
||||||
|
import dev.lions.unionflow.server.entity.Membre;
|
||||||
|
import dev.lions.unionflow.server.repository.CotisationRepository;
|
||||||
|
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||||
|
import io.quarkus.panache.common.Page;
|
||||||
|
import io.quarkus.panache.common.Sort;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service métier pour la gestion des cotisations
|
||||||
|
* Contient la logique métier et les règles de validation
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 1.0
|
||||||
|
* @since 2025-01-15
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
@Slf4j
|
||||||
|
public class CotisationService {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
CotisationRepository cotisationRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MembreRepository membreRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère toutes les cotisations avec pagination
|
||||||
|
*
|
||||||
|
* @param page numéro de page (0-based)
|
||||||
|
* @param size taille de la page
|
||||||
|
* @return liste des cotisations converties en DTO
|
||||||
|
*/
|
||||||
|
public List<CotisationDTO> getAllCotisations(int page, int size) {
|
||||||
|
log.debug("Récupération des cotisations - page: {}, size: {}", page, size);
|
||||||
|
|
||||||
|
List<Cotisation> cotisations = cotisationRepository.findAll(Sort.by("dateEcheance").descending())
|
||||||
|
.page(Page.of(page, size))
|
||||||
|
.list();
|
||||||
|
|
||||||
|
return cotisations.stream()
|
||||||
|
.map(this::convertToDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère une cotisation par son ID
|
||||||
|
*
|
||||||
|
* @param id identifiant de la cotisation
|
||||||
|
* @return DTO de la cotisation
|
||||||
|
* @throws NotFoundException si la cotisation n'existe pas
|
||||||
|
*/
|
||||||
|
public CotisationDTO getCotisationById(@NotNull Long id) {
|
||||||
|
log.debug("Récupération de la cotisation avec ID: {}", id);
|
||||||
|
|
||||||
|
Cotisation cotisation = cotisationRepository.findByIdOptional(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Cotisation non trouvée avec l'ID: " + id));
|
||||||
|
|
||||||
|
return convertToDTO(cotisation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère une cotisation par son numéro de référence
|
||||||
|
*
|
||||||
|
* @param numeroReference numéro de référence unique
|
||||||
|
* @return DTO de la cotisation
|
||||||
|
* @throws NotFoundException si la cotisation n'existe pas
|
||||||
|
*/
|
||||||
|
public CotisationDTO getCotisationByReference(@NotNull String numeroReference) {
|
||||||
|
log.debug("Récupération de la cotisation avec référence: {}", numeroReference);
|
||||||
|
|
||||||
|
Cotisation cotisation = cotisationRepository.findByNumeroReference(numeroReference)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Cotisation non trouvée avec la référence: " + numeroReference));
|
||||||
|
|
||||||
|
return convertToDTO(cotisation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une nouvelle cotisation
|
||||||
|
*
|
||||||
|
* @param cotisationDTO données de la cotisation à créer
|
||||||
|
* @return DTO de la cotisation créée
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public CotisationDTO createCotisation(@Valid CotisationDTO cotisationDTO) {
|
||||||
|
log.info("Création d'une nouvelle cotisation pour le membre: {}", cotisationDTO.getMembreId());
|
||||||
|
|
||||||
|
// Validation du membre
|
||||||
|
Membre membre = membreRepository.findByIdOptional(Long.valueOf(cotisationDTO.getMembreId().toString()))
|
||||||
|
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + cotisationDTO.getMembreId()));
|
||||||
|
|
||||||
|
// Conversion DTO vers entité
|
||||||
|
Cotisation cotisation = convertToEntity(cotisationDTO);
|
||||||
|
cotisation.setMembre(membre);
|
||||||
|
|
||||||
|
// Génération automatique du numéro de référence si absent
|
||||||
|
if (cotisation.getNumeroReference() == null || cotisation.getNumeroReference().isEmpty()) {
|
||||||
|
cotisation.setNumeroReference(Cotisation.genererNumeroReference());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation des règles métier
|
||||||
|
validateCotisationRules(cotisation);
|
||||||
|
|
||||||
|
// Persistance
|
||||||
|
cotisationRepository.persist(cotisation);
|
||||||
|
|
||||||
|
log.info("Cotisation créée avec succès - ID: {}, Référence: {}",
|
||||||
|
cotisation.id, cotisation.getNumeroReference());
|
||||||
|
|
||||||
|
return convertToDTO(cotisation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour une cotisation existante
|
||||||
|
*
|
||||||
|
* @param id identifiant de la cotisation
|
||||||
|
* @param cotisationDTO nouvelles données
|
||||||
|
* @return DTO de la cotisation mise à jour
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public CotisationDTO updateCotisation(@NotNull Long id, @Valid CotisationDTO cotisationDTO) {
|
||||||
|
log.info("Mise à jour de la cotisation avec ID: {}", id);
|
||||||
|
|
||||||
|
Cotisation cotisationExistante = cotisationRepository.findByIdOptional(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Cotisation non trouvée avec l'ID: " + id));
|
||||||
|
|
||||||
|
// Mise à jour des champs modifiables
|
||||||
|
updateCotisationFields(cotisationExistante, cotisationDTO);
|
||||||
|
|
||||||
|
// Validation des règles métier
|
||||||
|
validateCotisationRules(cotisationExistante);
|
||||||
|
|
||||||
|
log.info("Cotisation mise à jour avec succès - ID: {}", id);
|
||||||
|
|
||||||
|
return convertToDTO(cotisationExistante);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime (désactive) une cotisation
|
||||||
|
*
|
||||||
|
* @param id identifiant de la cotisation
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteCotisation(@NotNull Long id) {
|
||||||
|
log.info("Suppression de la cotisation avec ID: {}", id);
|
||||||
|
|
||||||
|
Cotisation cotisation = cotisationRepository.findByIdOptional(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Cotisation non trouvée avec l'ID: " + id));
|
||||||
|
|
||||||
|
// Vérification si la cotisation peut être supprimée
|
||||||
|
if ("PAYEE".equals(cotisation.getStatut())) {
|
||||||
|
throw new IllegalStateException("Impossible de supprimer une cotisation déjà payée");
|
||||||
|
}
|
||||||
|
|
||||||
|
cotisation.setStatut("ANNULEE");
|
||||||
|
|
||||||
|
log.info("Cotisation supprimée avec succès - ID: {}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les cotisations d'un membre
|
||||||
|
*
|
||||||
|
* @param membreId identifiant du membre
|
||||||
|
* @param page numéro de page
|
||||||
|
* @param size taille de la page
|
||||||
|
* @return liste des cotisations du membre
|
||||||
|
*/
|
||||||
|
public List<CotisationDTO> getCotisationsByMembre(@NotNull Long membreId, int page, int size) {
|
||||||
|
log.debug("Récupération des cotisations du membre: {}", membreId);
|
||||||
|
|
||||||
|
// Vérification de l'existence du membre
|
||||||
|
if (!membreRepository.findByIdOptional(membreId).isPresent()) {
|
||||||
|
throw new NotFoundException("Membre non trouvé avec l'ID: " + membreId);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Cotisation> cotisations = cotisationRepository.findByMembreId(membreId,
|
||||||
|
Page.of(page, size), Sort.by("dateEcheance").descending());
|
||||||
|
|
||||||
|
return cotisations.stream()
|
||||||
|
.map(this::convertToDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les cotisations par statut
|
||||||
|
*
|
||||||
|
* @param statut statut recherché
|
||||||
|
* @param page numéro de page
|
||||||
|
* @param size taille de la page
|
||||||
|
* @return liste des cotisations avec le statut spécifié
|
||||||
|
*/
|
||||||
|
public List<CotisationDTO> getCotisationsByStatut(@NotNull String statut, int page, int size) {
|
||||||
|
log.debug("Récupération des cotisations avec statut: {}", statut);
|
||||||
|
|
||||||
|
List<Cotisation> cotisations = cotisationRepository.findByStatut(statut, Page.of(page, size));
|
||||||
|
|
||||||
|
return cotisations.stream()
|
||||||
|
.map(this::convertToDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les cotisations en retard
|
||||||
|
*
|
||||||
|
* @param page numéro de page
|
||||||
|
* @param size taille de la page
|
||||||
|
* @return liste des cotisations en retard
|
||||||
|
*/
|
||||||
|
public List<CotisationDTO> getCotisationsEnRetard(int page, int size) {
|
||||||
|
log.debug("Récupération des cotisations en retard");
|
||||||
|
|
||||||
|
List<Cotisation> cotisations = cotisationRepository.findCotisationsEnRetard(
|
||||||
|
LocalDate.now(), Page.of(page, size));
|
||||||
|
|
||||||
|
return cotisations.stream()
|
||||||
|
.map(this::convertToDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche avancée de cotisations
|
||||||
|
*
|
||||||
|
* @param membreId identifiant du membre (optionnel)
|
||||||
|
* @param statut statut (optionnel)
|
||||||
|
* @param typeCotisation type (optionnel)
|
||||||
|
* @param annee année (optionnel)
|
||||||
|
* @param mois mois (optionnel)
|
||||||
|
* @param page numéro de page
|
||||||
|
* @param size taille de la page
|
||||||
|
* @return liste filtrée des cotisations
|
||||||
|
*/
|
||||||
|
public List<CotisationDTO> rechercherCotisations(Long membreId, String statut, String typeCotisation,
|
||||||
|
Integer annee, Integer mois, int page, int size) {
|
||||||
|
log.debug("Recherche avancée de cotisations avec filtres");
|
||||||
|
|
||||||
|
List<Cotisation> cotisations = cotisationRepository.rechercheAvancee(
|
||||||
|
membreId, statut, typeCotisation, annee, mois, Page.of(page, size));
|
||||||
|
|
||||||
|
return cotisations.stream()
|
||||||
|
.map(this::convertToDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les statistiques des cotisations
|
||||||
|
*
|
||||||
|
* @return map contenant les statistiques
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getStatistiquesCotisations() {
|
||||||
|
log.debug("Calcul des statistiques des cotisations");
|
||||||
|
|
||||||
|
long totalCotisations = cotisationRepository.count();
|
||||||
|
long cotisationsPayees = cotisationRepository.compterParStatut("PAYEE");
|
||||||
|
long cotisationsEnRetard = cotisationRepository.findCotisationsEnRetard(LocalDate.now(), Page.of(0, Integer.MAX_VALUE)).size();
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"totalCotisations", totalCotisations,
|
||||||
|
"cotisationsPayees", cotisationsPayees,
|
||||||
|
"cotisationsEnRetard", cotisationsEnRetard,
|
||||||
|
"tauxPaiement", totalCotisations > 0 ? (cotisationsPayees * 100.0 / totalCotisations) : 0.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une entité Cotisation en DTO
|
||||||
|
*/
|
||||||
|
private CotisationDTO convertToDTO(Cotisation cotisation) {
|
||||||
|
CotisationDTO dto = new CotisationDTO();
|
||||||
|
|
||||||
|
// Copie des propriétés de base
|
||||||
|
// Génération d'UUID basé sur l'ID numérique pour compatibilité
|
||||||
|
dto.setId(UUID.nameUUIDFromBytes(("cotisation-" + cotisation.id).getBytes()));
|
||||||
|
dto.setNumeroReference(cotisation.getNumeroReference());
|
||||||
|
dto.setMembreId(UUID.nameUUIDFromBytes(("membre-" + cotisation.getMembre().id).getBytes()));
|
||||||
|
dto.setNomMembre(cotisation.getMembre().getNom() + " " + cotisation.getMembre().getPrenom());
|
||||||
|
dto.setNumeroMembre(cotisation.getMembre().getNumeroMembre());
|
||||||
|
dto.setTypeCotisation(cotisation.getTypeCotisation());
|
||||||
|
dto.setMontantDu(cotisation.getMontantDu());
|
||||||
|
dto.setMontantPaye(cotisation.getMontantPaye());
|
||||||
|
dto.setCodeDevise(cotisation.getCodeDevise());
|
||||||
|
dto.setStatut(cotisation.getStatut());
|
||||||
|
dto.setDateEcheance(cotisation.getDateEcheance());
|
||||||
|
dto.setDatePaiement(cotisation.getDatePaiement());
|
||||||
|
dto.setDescription(cotisation.getDescription());
|
||||||
|
dto.setPeriode(cotisation.getPeriode());
|
||||||
|
dto.setAnnee(cotisation.getAnnee());
|
||||||
|
dto.setMois(cotisation.getMois());
|
||||||
|
dto.setObservations(cotisation.getObservations());
|
||||||
|
dto.setRecurrente(cotisation.getRecurrente());
|
||||||
|
dto.setNombreRappels(cotisation.getNombreRappels());
|
||||||
|
dto.setDateDernierRappel(cotisation.getDateDernierRappel());
|
||||||
|
dto.setValidePar(cotisation.getValideParId() != null ?
|
||||||
|
UUID.nameUUIDFromBytes(("user-" + cotisation.getValideParId()).getBytes()) : null);
|
||||||
|
dto.setNomValidateur(cotisation.getNomValidateur());
|
||||||
|
dto.setMethodePaiement(cotisation.getMethodePaiement());
|
||||||
|
dto.setReferencePaiement(cotisation.getReferencePaiement());
|
||||||
|
dto.setDateCreation(cotisation.getDateCreation());
|
||||||
|
dto.setDateModification(cotisation.getDateModification());
|
||||||
|
|
||||||
|
// Propriétés héritées de BaseDTO
|
||||||
|
dto.setActif(true); // Les cotisations sont toujours actives
|
||||||
|
dto.setVersion(0L); // Version par défaut
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit un DTO en entité Cotisation
|
||||||
|
*/
|
||||||
|
private Cotisation convertToEntity(CotisationDTO dto) {
|
||||||
|
return Cotisation.builder()
|
||||||
|
.numeroReference(dto.getNumeroReference())
|
||||||
|
.typeCotisation(dto.getTypeCotisation())
|
||||||
|
.montantDu(dto.getMontantDu())
|
||||||
|
.montantPaye(dto.getMontantPaye() != null ? dto.getMontantPaye() : BigDecimal.ZERO)
|
||||||
|
.codeDevise(dto.getCodeDevise() != null ? dto.getCodeDevise() : "XOF")
|
||||||
|
.statut(dto.getStatut() != null ? dto.getStatut() : "EN_ATTENTE")
|
||||||
|
.dateEcheance(dto.getDateEcheance())
|
||||||
|
.datePaiement(dto.getDatePaiement())
|
||||||
|
.description(dto.getDescription())
|
||||||
|
.periode(dto.getPeriode())
|
||||||
|
.annee(dto.getAnnee())
|
||||||
|
.mois(dto.getMois())
|
||||||
|
.observations(dto.getObservations())
|
||||||
|
.recurrente(dto.getRecurrente() != null ? dto.getRecurrente() : false)
|
||||||
|
.nombreRappels(dto.getNombreRappels() != null ? dto.getNombreRappels() : 0)
|
||||||
|
.dateDernierRappel(dto.getDateDernierRappel())
|
||||||
|
.methodePaiement(dto.getMethodePaiement())
|
||||||
|
.referencePaiement(dto.getReferencePaiement())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour les champs d'une cotisation existante
|
||||||
|
*/
|
||||||
|
private void updateCotisationFields(Cotisation cotisation, CotisationDTO dto) {
|
||||||
|
if (dto.getTypeCotisation() != null) {
|
||||||
|
cotisation.setTypeCotisation(dto.getTypeCotisation());
|
||||||
|
}
|
||||||
|
if (dto.getMontantDu() != null) {
|
||||||
|
cotisation.setMontantDu(dto.getMontantDu());
|
||||||
|
}
|
||||||
|
if (dto.getMontantPaye() != null) {
|
||||||
|
cotisation.setMontantPaye(dto.getMontantPaye());
|
||||||
|
}
|
||||||
|
if (dto.getStatut() != null) {
|
||||||
|
cotisation.setStatut(dto.getStatut());
|
||||||
|
}
|
||||||
|
if (dto.getDateEcheance() != null) {
|
||||||
|
cotisation.setDateEcheance(dto.getDateEcheance());
|
||||||
|
}
|
||||||
|
if (dto.getDatePaiement() != null) {
|
||||||
|
cotisation.setDatePaiement(dto.getDatePaiement());
|
||||||
|
}
|
||||||
|
if (dto.getDescription() != null) {
|
||||||
|
cotisation.setDescription(dto.getDescription());
|
||||||
|
}
|
||||||
|
if (dto.getObservations() != null) {
|
||||||
|
cotisation.setObservations(dto.getObservations());
|
||||||
|
}
|
||||||
|
if (dto.getMethodePaiement() != null) {
|
||||||
|
cotisation.setMethodePaiement(dto.getMethodePaiement());
|
||||||
|
}
|
||||||
|
if (dto.getReferencePaiement() != null) {
|
||||||
|
cotisation.setReferencePaiement(dto.getReferencePaiement());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide les règles métier pour une cotisation
|
||||||
|
*/
|
||||||
|
private void validateCotisationRules(Cotisation cotisation) {
|
||||||
|
// Validation du montant
|
||||||
|
if (cotisation.getMontantDu().compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw new IllegalArgumentException("Le montant dû doit être positif");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation de la date d'échéance
|
||||||
|
if (cotisation.getDateEcheance().isBefore(LocalDate.now().minusYears(1))) {
|
||||||
|
throw new IllegalArgumentException("La date d'échéance ne peut pas être antérieure à un an");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation du montant payé
|
||||||
|
if (cotisation.getMontantPaye().compareTo(cotisation.getMontantDu()) > 0) {
|
||||||
|
throw new IllegalArgumentException("Le montant payé ne peut pas dépasser le montant dû");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation de la cohérence statut/paiement
|
||||||
|
if ("PAYEE".equals(cotisation.getStatut()) &&
|
||||||
|
cotisation.getMontantPaye().compareTo(cotisation.getMontantDu()) < 0) {
|
||||||
|
throw new IllegalArgumentException("Une cotisation marquée comme payée doit avoir un montant payé égal au montant dû");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
package dev.lions.unionflow.server.service;
|
package dev.lions.unionflow.server.service;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.dto.membre.MembreDTO;
|
||||||
import dev.lions.unionflow.server.entity.Membre;
|
import dev.lions.unionflow.server.entity.Membre;
|
||||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||||
|
import io.quarkus.panache.common.Page;
|
||||||
|
import io.quarkus.panache.common.Sort;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.transaction.Transactional;
|
import jakarta.transaction.Transactional;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service métier pour les membres
|
* Service métier pour les membres
|
||||||
@@ -140,4 +146,153 @@ public class MembreService {
|
|||||||
public long compterMembresActifs() {
|
public long compterMembresActifs() {
|
||||||
return membreRepository.countActifs();
|
return membreRepository.countActifs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste tous les membres actifs avec pagination
|
||||||
|
*/
|
||||||
|
public List<Membre> listerMembresActifs(Page page, Sort sort) {
|
||||||
|
return membreRepository.findAllActifs(page, sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche des membres avec pagination
|
||||||
|
*/
|
||||||
|
public List<Membre> rechercherMembres(String recherche, Page page, Sort sort) {
|
||||||
|
return membreRepository.findByNomOrPrenom(recherche, page, sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient les statistiques avancées des membres
|
||||||
|
*/
|
||||||
|
public Map<String, Object> obtenirStatistiquesAvancees() {
|
||||||
|
LOG.info("Calcul des statistiques avancées des membres");
|
||||||
|
|
||||||
|
long totalMembres = membreRepository.count();
|
||||||
|
long membresActifs = membreRepository.countActifs();
|
||||||
|
long membresInactifs = totalMembres - membresActifs;
|
||||||
|
long nouveauxMembres30Jours = membreRepository.countNouveauxMembres(LocalDate.now().minusDays(30));
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"totalMembres", totalMembres,
|
||||||
|
"membresActifs", membresActifs,
|
||||||
|
"membresInactifs", membresInactifs,
|
||||||
|
"nouveauxMembres30Jours", nouveauxMembres30Jours,
|
||||||
|
"tauxActivite", totalMembres > 0 ? (membresActifs * 100.0 / totalMembres) : 0.0,
|
||||||
|
"timestamp", LocalDateTime.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// MÉTHODES DE CONVERSION DTO
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une entité Membre en MembreDTO
|
||||||
|
*/
|
||||||
|
public MembreDTO convertToDTO(Membre membre) {
|
||||||
|
if (membre == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
MembreDTO dto = new MembreDTO();
|
||||||
|
|
||||||
|
// Génération d'UUID basé sur l'ID numérique pour compatibilité
|
||||||
|
dto.setId(UUID.nameUUIDFromBytes(("membre-" + membre.id).getBytes()));
|
||||||
|
|
||||||
|
// Copie des champs de base
|
||||||
|
dto.setNumeroMembre(membre.getNumeroMembre());
|
||||||
|
dto.setNom(membre.getNom());
|
||||||
|
dto.setPrenom(membre.getPrenom());
|
||||||
|
dto.setEmail(membre.getEmail());
|
||||||
|
dto.setTelephone(membre.getTelephone());
|
||||||
|
dto.setDateNaissance(membre.getDateNaissance());
|
||||||
|
dto.setDateAdhesion(membre.getDateAdhesion());
|
||||||
|
|
||||||
|
// Conversion du statut boolean vers string
|
||||||
|
dto.setStatut(membre.getActif() ? "ACTIF" : "INACTIF");
|
||||||
|
|
||||||
|
// Champs de base DTO
|
||||||
|
dto.setDateCreation(membre.getDateCreation());
|
||||||
|
dto.setDateModification(membre.getDateModification());
|
||||||
|
dto.setVersion(0L); // Version par défaut
|
||||||
|
|
||||||
|
// Champs par défaut pour les champs manquants dans l'entité
|
||||||
|
dto.setAssociationId(1L); // Association par défaut
|
||||||
|
dto.setMembreBureau(false);
|
||||||
|
dto.setResponsable(false);
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit un MembreDTO en entité Membre
|
||||||
|
*/
|
||||||
|
public Membre convertFromDTO(MembreDTO dto) {
|
||||||
|
if (dto == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Membre membre = new Membre();
|
||||||
|
|
||||||
|
// Copie des champs
|
||||||
|
membre.setNumeroMembre(dto.getNumeroMembre());
|
||||||
|
membre.setNom(dto.getNom());
|
||||||
|
membre.setPrenom(dto.getPrenom());
|
||||||
|
membre.setEmail(dto.getEmail());
|
||||||
|
membre.setTelephone(dto.getTelephone());
|
||||||
|
membre.setDateNaissance(dto.getDateNaissance());
|
||||||
|
membre.setDateAdhesion(dto.getDateAdhesion());
|
||||||
|
|
||||||
|
// Conversion du statut string vers boolean
|
||||||
|
membre.setActif("ACTIF".equals(dto.getStatut()));
|
||||||
|
|
||||||
|
// Champs de base
|
||||||
|
if (dto.getDateCreation() != null) {
|
||||||
|
membre.setDateCreation(dto.getDateCreation());
|
||||||
|
}
|
||||||
|
if (dto.getDateModification() != null) {
|
||||||
|
membre.setDateModification(dto.getDateModification());
|
||||||
|
}
|
||||||
|
|
||||||
|
return membre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une liste d'entités en liste de DTOs
|
||||||
|
*/
|
||||||
|
public List<MembreDTO> convertToDTOList(List<Membre> membres) {
|
||||||
|
return membres.stream()
|
||||||
|
.map(this::convertToDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour une entité Membre à partir d'un MembreDTO
|
||||||
|
*/
|
||||||
|
public void updateFromDTO(Membre membre, MembreDTO dto) {
|
||||||
|
if (membre == null || dto == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mise à jour des champs modifiables
|
||||||
|
membre.setPrenom(dto.getPrenom());
|
||||||
|
membre.setNom(dto.getNom());
|
||||||
|
membre.setEmail(dto.getEmail());
|
||||||
|
membre.setTelephone(dto.getTelephone());
|
||||||
|
membre.setDateNaissance(dto.getDateNaissance());
|
||||||
|
membre.setActif("ACTIF".equals(dto.getStatut()));
|
||||||
|
membre.setDateModification(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche avancée de membres avec filtres multiples
|
||||||
|
*/
|
||||||
|
public List<Membre> rechercheAvancee(String recherche, Boolean actif,
|
||||||
|
LocalDate dateAdhesionMin, LocalDate dateAdhesionMax,
|
||||||
|
Page page, Sort sort) {
|
||||||
|
LOG.infof("Recherche avancée - recherche: %s, actif: %s, dateMin: %s, dateMax: %s",
|
||||||
|
recherche, actif, dateAdhesionMin, dateAdhesionMax);
|
||||||
|
|
||||||
|
return membreRepository.rechercheAvancee(recherche, actif, dateAdhesionMin, dateAdhesionMax, page, sort);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,9 +71,12 @@ quarkus:
|
|||||||
level: INFO
|
level: INFO
|
||||||
format: "%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{2.}] (%t) %s%e%n"
|
format: "%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{2.}] (%t) %s%e%n"
|
||||||
category:
|
category:
|
||||||
"dev.lions.unionflow": INFO
|
"dev.lions.unionflow":
|
||||||
"org.hibernate": WARN
|
level: INFO
|
||||||
"io.quarkus": INFO
|
"org.hibernate":
|
||||||
|
level: WARN
|
||||||
|
"io.quarkus":
|
||||||
|
level: INFO
|
||||||
|
|
||||||
---
|
---
|
||||||
# Profil de développement
|
# Profil de développement
|
||||||
@@ -94,8 +97,10 @@ quarkus:
|
|||||||
migrate-at-start: false
|
migrate-at-start: false
|
||||||
log:
|
log:
|
||||||
category:
|
category:
|
||||||
"dev.lions.unionflow": DEBUG
|
"dev.lions.unionflow":
|
||||||
"org.hibernate.SQL": DEBUG
|
level: DEBUG
|
||||||
|
"org.hibernate.SQL":
|
||||||
|
level: DEBUG
|
||||||
|
|
||||||
---
|
---
|
||||||
# Profil de test
|
# Profil de test
|
||||||
@@ -126,5 +131,7 @@ quarkus:
|
|||||||
console:
|
console:
|
||||||
level: WARN
|
level: WARN
|
||||||
category:
|
category:
|
||||||
"dev.lions.unionflow": INFO
|
"dev.lions.unionflow":
|
||||||
root: WARN
|
level: INFO
|
||||||
|
root:
|
||||||
|
level: WARN
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- Script d'insertion de données de test pour UnionFlow
|
||||||
|
-- Ce fichier sera exécuté automatiquement par Quarkus au démarrage en mode dev
|
||||||
|
|
||||||
|
-- Insertion de membres de test
|
||||||
|
INSERT INTO membre (id, nom, prenom, email, telephone, date_naissance, adresse, profession, statut, date_adhesion, numero_membre, created_at, updated_at) VALUES
|
||||||
|
('550e8400-e29b-41d4-a716-446655440001', 'Kouassi', 'Jean-Baptiste', 'jb.kouassi@email.ci', '+225 07 12 34 56 78', '1985-03-15', 'Cocody, Abidjan', 'Ingénieur Informatique', 'ACTIF', '2023-01-15', 'MBR001', NOW(), NOW()),
|
||||||
|
('550e8400-e29b-41d4-a716-446655440002', 'Traoré', 'Aminata', 'aminata.traore@email.ci', '+225 05 98 76 54 32', '1990-07-22', 'Plateau, Abidjan', 'Comptable', 'ACTIF', '2023-02-10', 'MBR002', NOW(), NOW()),
|
||||||
|
('550e8400-e29b-41d4-a716-446655440003', 'Bamba', 'Seydou', 'seydou.bamba@email.ci', '+225 01 23 45 67 89', '1988-11-08', 'Yopougon, Abidjan', 'Commerçant', 'ACTIF', '2023-03-05', 'MBR003', NOW(), NOW()),
|
||||||
|
('550e8400-e29b-41d4-a716-446655440004', 'Ouattara', 'Fatoumata', 'fatoumata.ouattara@email.ci', '+225 07 87 65 43 21', '1992-05-18', 'Adjamé, Abidjan', 'Enseignante', 'ACTIF', '2023-04-12', 'MBR004', NOW(), NOW()),
|
||||||
|
('550e8400-e29b-41d4-a716-446655440005', 'Koné', 'Ibrahim', 'ibrahim.kone@email.ci', '+225 05 11 22 33 44', '1987-09-30', 'Marcory, Abidjan', 'Médecin', 'ACTIF', '2023-05-20', 'MBR005', NOW(), NOW()),
|
||||||
|
('550e8400-e29b-41d4-a716-446655440006', 'Diabaté', 'Mariam', 'mariam.diabate@email.ci', '+225 01 55 66 77 88', '1991-12-03', 'Treichville, Abidjan', 'Avocate', 'SUSPENDU', '2023-06-08', 'MBR006', NOW(), NOW()),
|
||||||
|
('550e8400-e29b-41d4-a716-446655440007', 'Sangaré', 'Moussa', 'moussa.sangare@email.ci', '+225 07 99 88 77 66', '1989-04-25', 'Koumassi, Abidjan', 'Pharmacien', 'ACTIF', '2023-07-15', 'MBR007', NOW(), NOW()),
|
||||||
|
('550e8400-e29b-41d4-a716-446655440008', 'Coulibaly', 'Awa', 'awa.coulibaly@email.ci', '+225 05 44 33 22 11', '1993-08-14', 'Port-Bouët, Abidjan', 'Architecte', 'ACTIF', '2023-08-22', 'MBR008', NOW(), NOW());
|
||||||
|
|
||||||
|
-- Insertion de cotisations de test avec différents statuts
|
||||||
|
INSERT INTO cotisation (id, numero_reference, membre_id, nom_membre, type_cotisation, montant_du, montant_paye, statut, date_echeance, date_creation, periode, description, created_at, updated_at) VALUES
|
||||||
|
-- Cotisations payées
|
||||||
|
('660e8400-e29b-41d4-a716-446655440001', 'COT-2024-001', '550e8400-e29b-41d4-a716-446655440001', 'Jean-Baptiste Kouassi', 'MENSUELLE', 25000, 25000, 'PAYEE', '2024-01-31', '2024-01-01', 'Janvier 2024', 'Cotisation mensuelle janvier', NOW(), NOW()),
|
||||||
|
('660e8400-e29b-41d4-a716-446655440002', 'COT-2024-002', '550e8400-e29b-41d4-a716-446655440002', 'Aminata Traoré', 'MENSUELLE', 25000, 25000, 'PAYEE', '2024-01-31', '2024-01-01', 'Janvier 2024', 'Cotisation mensuelle janvier', NOW(), NOW()),
|
||||||
|
('660e8400-e29b-41d4-a716-446655440003', 'COT-2024-003', '550e8400-e29b-41d4-a716-446655440003', 'Seydou Bamba', 'MENSUELLE', 25000, 25000, 'PAYEE', '2024-02-29', '2024-02-01', 'Février 2024', 'Cotisation mensuelle février', NOW(), NOW()),
|
||||||
|
|
||||||
|
-- Cotisations en attente
|
||||||
|
('660e8400-e29b-41d4-a716-446655440004', 'COT-2024-004', '550e8400-e29b-41d4-a716-446655440004', 'Fatoumata Ouattara', 'MENSUELLE', 25000, 0, 'EN_ATTENTE', '2024-12-31', '2024-12-01', 'Décembre 2024', 'Cotisation mensuelle décembre', NOW(), NOW()),
|
||||||
|
('660e8400-e29b-41d4-a716-446655440005', 'COT-2024-005', '550e8400-e29b-41d4-a716-446655440005', 'Ibrahim Koné', 'MENSUELLE', 25000, 0, 'EN_ATTENTE', '2024-12-31', '2024-12-01', 'Décembre 2024', 'Cotisation mensuelle décembre', NOW(), NOW()),
|
||||||
|
|
||||||
|
-- Cotisations en retard
|
||||||
|
('660e8400-e29b-41d4-a716-446655440006', 'COT-2024-006', '550e8400-e29b-41d4-a716-446655440006', 'Mariam Diabaté', 'MENSUELLE', 25000, 0, 'EN_RETARD', '2024-11-30', '2024-11-01', 'Novembre 2024', 'Cotisation mensuelle novembre', NOW(), NOW()),
|
||||||
|
('660e8400-e29b-41d4-a716-446655440007', 'COT-2024-007', '550e8400-e29b-41d4-a716-446655440007', 'Moussa Sangaré', 'MENSUELLE', 25000, 0, 'EN_RETARD', '2024-10-31', '2024-10-01', 'Octobre 2024', 'Cotisation mensuelle octobre', NOW(), NOW()),
|
||||||
|
|
||||||
|
-- Cotisations partiellement payées
|
||||||
|
('660e8400-e29b-41d4-a716-446655440008', 'COT-2024-008', '550e8400-e29b-41d4-a716-446655440008', 'Awa Coulibaly', 'MENSUELLE', 25000, 15000, 'PARTIELLEMENT_PAYEE', '2024-12-31', '2024-12-01', 'Décembre 2024', 'Cotisation mensuelle décembre', NOW(), NOW()),
|
||||||
|
|
||||||
|
-- Cotisations spéciales (adhésion, événements)
|
||||||
|
('660e8400-e29b-41d4-a716-446655440009', 'COT-2024-009', '550e8400-e29b-41d4-a716-446655440001', 'Jean-Baptiste Kouassi', 'ADHESION', 50000, 50000, 'PAYEE', '2024-01-15', '2024-01-01', 'Adhésion 2024', 'Frais d''adhésion annuelle', NOW(), NOW()),
|
||||||
|
('660e8400-e29b-41d4-a716-446655440010', 'COT-2024-010', '550e8400-e29b-41d4-a716-446655440002', 'Aminata Traoré', 'EVENEMENT', 15000, 0, 'EN_ATTENTE', '2024-12-25', '2024-12-01', 'Fête de fin d''année', 'Participation à la fête de fin d''année', NOW(), NOW()),
|
||||||
|
('660e8400-e29b-41d4-a716-446655440011', 'COT-2024-011', '550e8400-e29b-41d4-a716-446655440003', 'Seydou Bamba', 'SOLIDARITE', 10000, 10000, 'PAYEE', '2024-11-15', '2024-11-01', 'Aide mutuelle', 'Contribution solidarité membre en difficulté', NOW(), NOW()),
|
||||||
|
|
||||||
|
-- Cotisations annuelles
|
||||||
|
('660e8400-e29b-41d4-a716-446655440012', 'COT-2024-012', '550e8400-e29b-41d4-a716-446655440004', 'Fatoumata Ouattara', 'ANNUELLE', 300000, 150000, 'PARTIELLEMENT_PAYEE', '2024-12-31', '2024-01-01', 'Cotisation annuelle 2024', 'Cotisation annuelle avec paiement échelonné', NOW(), NOW()),
|
||||||
|
('660e8400-e29b-41d4-a716-446655440013', 'COT-2024-013', '550e8400-e29b-41d4-a716-446655440005', 'Ibrahim Koné', 'ANNUELLE', 300000, 0, 'EN_RETARD', '2024-06-30', '2024-01-01', 'Cotisation annuelle 2024', 'Cotisation annuelle en retard', NOW(), NOW()),
|
||||||
|
|
||||||
|
-- Cotisations diverses montants
|
||||||
|
('660e8400-e29b-41d4-a716-446655440014', 'COT-2024-014', '550e8400-e29b-41d4-a716-446655440007', 'Moussa Sangaré', 'FORMATION', 75000, 75000, 'PAYEE', '2024-09-30', '2024-09-01', 'Formation professionnelle', 'Participation formation développement personnel', NOW(), NOW()),
|
||||||
|
('660e8400-e29b-41d4-a716-446655440015', 'COT-2024-015', '550e8400-e29b-41d4-a716-446655440008', 'Awa Coulibaly', 'PROJET', 100000, 25000, 'PARTIELLEMENT_PAYEE', '2024-12-31', '2024-11-01', 'Projet communautaire', 'Financement projet construction école', NOW(), NOW());
|
||||||
44
unionflow-server-impl-quarkus/src/main/resources/import.sql
Normal file
44
unionflow-server-impl-quarkus/src/main/resources/import.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- Script d'insertion de données de test pour UnionFlow
|
||||||
|
-- Ce fichier sera exécuté automatiquement par Quarkus au démarrage
|
||||||
|
|
||||||
|
-- Insertion de membres de test
|
||||||
|
INSERT INTO membres (id, numero_membre, nom, prenom, email, telephone, date_naissance, date_adhesion, actif, date_creation) VALUES
|
||||||
|
(1, 'MBR001', 'Kouassi', 'Jean-Baptiste', 'jb.kouassi@email.ci', '+225071234567', '1985-03-15', '2023-01-15', true, '2024-01-01 10:00:00'),
|
||||||
|
(2, 'MBR002', 'Traoré', 'Aminata', 'aminata.traore@email.ci', '+225059876543', '1990-07-22', '2023-02-10', true, '2024-01-01 10:00:00'),
|
||||||
|
(3, 'MBR003', 'Bamba', 'Seydou', 'seydou.bamba@email.ci', '+225012345678', '1988-11-08', '2023-03-05', true, '2024-01-01 10:00:00'),
|
||||||
|
(4, 'MBR004', 'Ouattara', 'Fatoumata', 'fatoumata.ouattara@email.ci', '+225078765432', '1992-05-18', '2023-04-12', true, '2024-01-01 10:00:00'),
|
||||||
|
(5, 'MBR005', 'Koné', 'Ibrahim', 'ibrahim.kone@email.ci', '+225051122334', '1987-09-30', '2023-05-20', true, '2024-01-01 10:00:00'),
|
||||||
|
(6, 'MBR006', 'Diabaté', 'Mariam', 'mariam.diabate@email.ci', '+225015566778', '1991-12-03', '2023-06-08', false, '2024-01-01 10:00:00'),
|
||||||
|
(7, 'MBR007', 'Sangaré', 'Moussa', 'moussa.sangare@email.ci', '+225079988776', '1989-04-25', '2023-07-15', true, '2024-01-01 10:00:00'),
|
||||||
|
(8, 'MBR008', 'Coulibaly', 'Awa', 'awa.coulibaly@email.ci', '+225054433221', '1993-08-14', '2023-08-22', true, '2024-01-01 10:00:00');
|
||||||
|
|
||||||
|
-- Insertion de cotisations de test avec différents statuts
|
||||||
|
INSERT INTO cotisations (id, numero_reference, membre_id, type_cotisation, montant_du, montant_paye, statut, date_echeance, date_creation, periode, description, annee, mois, code_devise, recurrente, nombre_rappels) VALUES
|
||||||
|
-- Cotisations payées
|
||||||
|
(1, 'COT-2024-001', 1, 'MENSUELLE', 25000.00, 25000.00, 'PAYEE', '2024-01-31', '2024-01-01 10:00:00', 'Janvier 2024', 'Cotisation mensuelle janvier', 2024, 1, 'XOF', true, 0),
|
||||||
|
(2, 'COT-2024-002', 2, 'MENSUELLE', 25000.00, 25000.00, 'PAYEE', '2024-01-31', '2024-01-01 10:00:00', 'Janvier 2024', 'Cotisation mensuelle janvier', 2024, 1, 'XOF', true, 0),
|
||||||
|
(3, 'COT-2024-003', 3, 'MENSUELLE', 25000.00, 25000.00, 'PAYEE', '2024-02-29', '2024-02-01 10:00:00', 'Février 2024', 'Cotisation mensuelle février', 2024, 2, 'XOF', true, 0),
|
||||||
|
|
||||||
|
-- Cotisations en attente
|
||||||
|
(4, 'COT-2024-004', 4, 'MENSUELLE', 25000.00, 0.00, 'EN_ATTENTE', '2024-12-31', '2024-12-01 10:00:00', 'Décembre 2024', 'Cotisation mensuelle décembre', 2024, 12, 'XOF', true, 0),
|
||||||
|
(5, 'COT-2024-005', 5, 'MENSUELLE', 25000.00, 0.00, 'EN_ATTENTE', '2024-12-31', '2024-12-01 10:00:00', 'Décembre 2024', 'Cotisation mensuelle décembre', 2024, 12, 'XOF', true, 0),
|
||||||
|
|
||||||
|
-- Cotisations en retard
|
||||||
|
(6, 'COT-2024-006', 6, 'MENSUELLE', 25000.00, 0.00, 'EN_RETARD', '2024-11-30', '2024-11-01 10:00:00', 'Novembre 2024', 'Cotisation mensuelle novembre', 2024, 11, 'XOF', true, 2),
|
||||||
|
(7, 'COT-2024-007', 7, 'MENSUELLE', 25000.00, 0.00, 'EN_RETARD', '2024-10-31', '2024-10-01 10:00:00', 'Octobre 2024', 'Cotisation mensuelle octobre', 2024, 10, 'XOF', true, 3),
|
||||||
|
|
||||||
|
-- Cotisations partiellement payées
|
||||||
|
(8, 'COT-2024-008', 8, 'MENSUELLE', 25000.00, 15000.00, 'PARTIELLEMENT_PAYEE', '2024-12-31', '2024-12-01 10:00:00', 'Décembre 2024', 'Cotisation mensuelle décembre', 2024, 12, 'XOF', true, 1),
|
||||||
|
|
||||||
|
-- Cotisations spéciales
|
||||||
|
(9, 'COT-2024-009', 1, 'ADHESION', 50000.00, 50000.00, 'PAYEE', '2024-01-15', '2024-01-01 10:00:00', 'Adhésion 2024', 'Frais d''adhésion annuelle', 2024, null, 'XOF', false, 0),
|
||||||
|
(10, 'COT-2024-010', 2, 'EVENEMENT', 15000.00, 15000.00, 'PAYEE', '2024-06-15', '2024-06-01 10:00:00', 'Assemblée Générale', 'Participation assemblée générale', 2024, 6, 'XOF', false, 0),
|
||||||
|
(11, 'COT-2024-011', 3, 'SOLIDARITE', 10000.00, 10000.00, 'PAYEE', '2024-03-31', '2024-03-01 10:00:00', 'Aide Solidarité', 'Contribution solidarité membre', 2024, 3, 'XOF', false, 0),
|
||||||
|
(12, 'COT-2024-012', 4, 'ANNUELLE', 300000.00, 0.00, 'EN_ATTENTE', '2024-12-31', '2024-01-01 10:00:00', 'Annuelle 2024', 'Cotisation annuelle complète', 2024, null, 'XOF', false, 0),
|
||||||
|
(13, 'COT-2024-013', 5, 'FORMATION', 75000.00, 75000.00, 'PAYEE', '2024-09-30', '2024-09-01 10:00:00', 'Formation 2024', 'Formation en leadership associatif', 2024, 9, 'XOF', false, 0),
|
||||||
|
(14, 'COT-2024-014', 6, 'PROJET', 100000.00, 50000.00, 'PARTIELLEMENT_PAYEE', '2024-12-31', '2024-11-01 10:00:00', 'Projet Communauté', 'Contribution projet développement', 2024, 11, 'XOF', false, 1),
|
||||||
|
(15, 'COT-2024-015', 7, 'MENSUELLE', 25000.00, 0.00, 'EN_RETARD', '2024-09-30', '2024-09-01 10:00:00', 'Septembre 2024', 'Cotisation mensuelle septembre', 2024, 9, 'XOF', true, 4);
|
||||||
|
|
||||||
|
-- Mise à jour des séquences pour éviter les conflits
|
||||||
|
ALTER SEQUENCE membres_SEQ RESTART WITH 50;
|
||||||
|
ALTER SEQUENCE cotisations_SEQ RESTART WITH 50;
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package dev.lions.unionflow.server.resource;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.dto.finance.CotisationDTO;
|
||||||
|
import dev.lions.unionflow.server.entity.Cotisation;
|
||||||
|
import dev.lions.unionflow.server.entity.Membre;
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import io.restassured.http.ContentType;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.TestMethodOrder;
|
||||||
|
import org.junit.jupiter.api.MethodOrderer;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.Matchers.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests d'intégration pour CotisationResource
|
||||||
|
* Teste tous les endpoints REST de l'API cotisations
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 1.0
|
||||||
|
* @since 2025-01-15
|
||||||
|
*/
|
||||||
|
@QuarkusTest
|
||||||
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
|
@DisplayName("Tests d'intégration - API Cotisations")
|
||||||
|
class CotisationResourceTest {
|
||||||
|
|
||||||
|
private static Long membreTestId;
|
||||||
|
private static Long cotisationTestId;
|
||||||
|
private static String numeroReferenceTest;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
@Transactional
|
||||||
|
void setUp() {
|
||||||
|
// Nettoyage et création des données de test
|
||||||
|
Cotisation.deleteAll();
|
||||||
|
Membre.deleteAll();
|
||||||
|
|
||||||
|
// Création d'un membre de test
|
||||||
|
Membre membreTest = new Membre();
|
||||||
|
membreTest.setNumeroMembre("MBR-TEST-001");
|
||||||
|
membreTest.setNom("Dupont");
|
||||||
|
membreTest.setPrenom("Jean");
|
||||||
|
membreTest.setEmail("jean.dupont@test.com");
|
||||||
|
membreTest.setTelephone("+225070123456");
|
||||||
|
membreTest.setDateNaissance(LocalDate.of(1985, 5, 15));
|
||||||
|
membreTest.setActif(true);
|
||||||
|
membreTest.persist();
|
||||||
|
|
||||||
|
membreTestId = membreTest.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.Order(1)
|
||||||
|
@DisplayName("POST /api/cotisations - Création d'une cotisation")
|
||||||
|
void testCreateCotisation() {
|
||||||
|
CotisationDTO nouvelleCotisation = new CotisationDTO();
|
||||||
|
nouvelleCotisation.setMembreId(UUID.fromString(membreTestId.toString()));
|
||||||
|
nouvelleCotisation.setTypeCotisation("MENSUELLE");
|
||||||
|
nouvelleCotisation.setMontantDu(new BigDecimal("25000.00"));
|
||||||
|
nouvelleCotisation.setDateEcheance(LocalDate.now().plusDays(30));
|
||||||
|
nouvelleCotisation.setDescription("Cotisation mensuelle janvier 2025");
|
||||||
|
nouvelleCotisation.setPeriode("Janvier 2025");
|
||||||
|
nouvelleCotisation.setAnnee(2025);
|
||||||
|
nouvelleCotisation.setMois(1);
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(nouvelleCotisation)
|
||||||
|
.when()
|
||||||
|
.post("/api/cotisations")
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.body("numeroReference", notNullValue())
|
||||||
|
.body("membreId", equalTo(membreTestId.toString()))
|
||||||
|
.body("typeCotisation", equalTo("MENSUELLE"))
|
||||||
|
.body("montantDu", equalTo(25000.00f))
|
||||||
|
.body("montantPaye", equalTo(0.0f))
|
||||||
|
.body("statut", equalTo("EN_ATTENTE"))
|
||||||
|
.body("codeDevise", equalTo("XOF"))
|
||||||
|
.body("annee", equalTo(2025))
|
||||||
|
.body("mois", equalTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.Order(2)
|
||||||
|
@DisplayName("GET /api/cotisations - Liste des cotisations")
|
||||||
|
void testGetAllCotisations() {
|
||||||
|
given()
|
||||||
|
.queryParam("page", 0)
|
||||||
|
.queryParam("size", 10)
|
||||||
|
.when()
|
||||||
|
.get("/api/cotisations")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("size()", greaterThanOrEqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.Order(3)
|
||||||
|
@DisplayName("GET /api/cotisations/{id} - Récupération par ID")
|
||||||
|
void testGetCotisationById() {
|
||||||
|
// Créer d'abord une cotisation
|
||||||
|
CotisationDTO cotisation = createTestCotisation();
|
||||||
|
cotisationTestId = Long.valueOf(cotisation.getId().toString());
|
||||||
|
|
||||||
|
given()
|
||||||
|
.pathParam("id", cotisationTestId)
|
||||||
|
.when()
|
||||||
|
.get("/api/cotisations/{id}")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("id", equalTo(cotisationTestId.toString()))
|
||||||
|
.body("typeCotisation", equalTo("MENSUELLE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.Order(4)
|
||||||
|
@DisplayName("GET /api/cotisations/reference/{numeroReference} - Récupération par référence")
|
||||||
|
void testGetCotisationByReference() {
|
||||||
|
// Utiliser la cotisation créée précédemment
|
||||||
|
if (numeroReferenceTest == null) {
|
||||||
|
CotisationDTO cotisation = createTestCotisation();
|
||||||
|
numeroReferenceTest = cotisation.getNumeroReference();
|
||||||
|
}
|
||||||
|
|
||||||
|
given()
|
||||||
|
.pathParam("numeroReference", numeroReferenceTest)
|
||||||
|
.when()
|
||||||
|
.get("/api/cotisations/reference/{numeroReference}")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("numeroReference", equalTo(numeroReferenceTest))
|
||||||
|
.body("typeCotisation", equalTo("MENSUELLE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.Order(5)
|
||||||
|
@DisplayName("PUT /api/cotisations/{id} - Mise à jour d'une cotisation")
|
||||||
|
void testUpdateCotisation() {
|
||||||
|
// Créer une cotisation si nécessaire
|
||||||
|
if (cotisationTestId == null) {
|
||||||
|
CotisationDTO cotisation = createTestCotisation();
|
||||||
|
cotisationTestId = Long.valueOf(cotisation.getId().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
CotisationDTO cotisationMiseAJour = new CotisationDTO();
|
||||||
|
cotisationMiseAJour.setTypeCotisation("TRIMESTRIELLE");
|
||||||
|
cotisationMiseAJour.setMontantDu(new BigDecimal("75000.00"));
|
||||||
|
cotisationMiseAJour.setDescription("Cotisation trimestrielle Q1 2025");
|
||||||
|
cotisationMiseAJour.setObservations("Mise à jour du type de cotisation");
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.pathParam("id", cotisationTestId)
|
||||||
|
.body(cotisationMiseAJour)
|
||||||
|
.when()
|
||||||
|
.put("/api/cotisations/{id}")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("typeCotisation", equalTo("TRIMESTRIELLE"))
|
||||||
|
.body("montantDu", equalTo(75000.00f))
|
||||||
|
.body("observations", equalTo("Mise à jour du type de cotisation"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.Order(6)
|
||||||
|
@DisplayName("GET /api/cotisations/membre/{membreId} - Cotisations d'un membre")
|
||||||
|
void testGetCotisationsByMembre() {
|
||||||
|
given()
|
||||||
|
.pathParam("membreId", membreTestId)
|
||||||
|
.queryParam("page", 0)
|
||||||
|
.queryParam("size", 10)
|
||||||
|
.when()
|
||||||
|
.get("/api/cotisations/membre/{membreId}")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("size()", greaterThanOrEqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.Order(7)
|
||||||
|
@DisplayName("GET /api/cotisations/statut/{statut} - Cotisations par statut")
|
||||||
|
void testGetCotisationsByStatut() {
|
||||||
|
given()
|
||||||
|
.pathParam("statut", "EN_ATTENTE")
|
||||||
|
.queryParam("page", 0)
|
||||||
|
.queryParam("size", 10)
|
||||||
|
.when()
|
||||||
|
.get("/api/cotisations/statut/{statut}")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("size()", greaterThanOrEqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.Order(8)
|
||||||
|
@DisplayName("GET /api/cotisations/en-retard - Cotisations en retard")
|
||||||
|
void testGetCotisationsEnRetard() {
|
||||||
|
given()
|
||||||
|
.queryParam("page", 0)
|
||||||
|
.queryParam("size", 10)
|
||||||
|
.when()
|
||||||
|
.get("/api/cotisations/en-retard")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("size()", greaterThanOrEqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.Order(9)
|
||||||
|
@DisplayName("GET /api/cotisations/recherche - Recherche avancée")
|
||||||
|
void testRechercherCotisations() {
|
||||||
|
given()
|
||||||
|
.queryParam("membreId", membreTestId)
|
||||||
|
.queryParam("statut", "EN_ATTENTE")
|
||||||
|
.queryParam("annee", 2025)
|
||||||
|
.queryParam("page", 0)
|
||||||
|
.queryParam("size", 10)
|
||||||
|
.when()
|
||||||
|
.get("/api/cotisations/recherche")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("size()", greaterThanOrEqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.Order(10)
|
||||||
|
@DisplayName("GET /api/cotisations/stats - Statistiques des cotisations")
|
||||||
|
void testGetStatistiquesCotisations() {
|
||||||
|
given()
|
||||||
|
.when()
|
||||||
|
.get("/api/cotisations/stats")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("totalCotisations", notNullValue())
|
||||||
|
.body("cotisationsPayees", notNullValue())
|
||||||
|
.body("cotisationsEnRetard", notNullValue())
|
||||||
|
.body("tauxPaiement", notNullValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@org.junit.jupiter.api.Order(11)
|
||||||
|
@DisplayName("DELETE /api/cotisations/{id} - Suppression d'une cotisation")
|
||||||
|
void testDeleteCotisation() {
|
||||||
|
// Créer une cotisation si nécessaire
|
||||||
|
if (cotisationTestId == null) {
|
||||||
|
CotisationDTO cotisation = createTestCotisation();
|
||||||
|
cotisationTestId = Long.valueOf(cotisation.getId().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
given()
|
||||||
|
.pathParam("id", cotisationTestId)
|
||||||
|
.when()
|
||||||
|
.delete("/api/cotisations/{id}")
|
||||||
|
.then()
|
||||||
|
.statusCode(204);
|
||||||
|
|
||||||
|
// Vérifier que la cotisation est marquée comme annulée
|
||||||
|
given()
|
||||||
|
.pathParam("id", cotisationTestId)
|
||||||
|
.when()
|
||||||
|
.get("/api/cotisations/{id}")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("statut", equalTo("ANNULEE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("GET /api/cotisations/{id} - Cotisation inexistante")
|
||||||
|
void testGetCotisationByIdNotFound() {
|
||||||
|
given()
|
||||||
|
.pathParam("id", 99999L)
|
||||||
|
.when()
|
||||||
|
.get("/api/cotisations/{id}")
|
||||||
|
.then()
|
||||||
|
.statusCode(404)
|
||||||
|
.body("error", equalTo("Cotisation non trouvée"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("POST /api/cotisations - Données invalides")
|
||||||
|
void testCreateCotisationInvalidData() {
|
||||||
|
CotisationDTO cotisationInvalide = new CotisationDTO();
|
||||||
|
// Données manquantes ou invalides
|
||||||
|
cotisationInvalide.setTypeCotisation("");
|
||||||
|
cotisationInvalide.setMontantDu(new BigDecimal("-100"));
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(cotisationInvalide)
|
||||||
|
.when()
|
||||||
|
.post("/api/cotisations")
|
||||||
|
.then()
|
||||||
|
.statusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Méthode utilitaire pour créer une cotisation de test
|
||||||
|
*/
|
||||||
|
private CotisationDTO createTestCotisation() {
|
||||||
|
CotisationDTO cotisation = new CotisationDTO();
|
||||||
|
cotisation.setMembreId(UUID.fromString(membreTestId.toString()));
|
||||||
|
cotisation.setTypeCotisation("MENSUELLE");
|
||||||
|
cotisation.setMontantDu(new BigDecimal("25000.00"));
|
||||||
|
cotisation.setDateEcheance(LocalDate.now().plusDays(30));
|
||||||
|
cotisation.setDescription("Cotisation de test");
|
||||||
|
cotisation.setPeriode("Test 2025");
|
||||||
|
cotisation.setAnnee(2025);
|
||||||
|
cotisation.setMois(1);
|
||||||
|
|
||||||
|
return given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(cotisation)
|
||||||
|
.when()
|
||||||
|
.post("/api/cotisations")
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.as(CotisationDTO.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package dev.lions.unionflow.server.resource;
|
package dev.lions.unionflow.server.resource;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.dto.membre.MembreDTO;
|
||||||
import dev.lions.unionflow.server.entity.Membre;
|
import dev.lions.unionflow.server.entity.Membre;
|
||||||
import dev.lions.unionflow.server.service.MembreService;
|
import dev.lions.unionflow.server.service.MembreService;
|
||||||
|
import io.quarkus.panache.common.Page;
|
||||||
|
import io.quarkus.panache.common.Sort;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -17,9 +20,7 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,14 +116,20 @@ class MembreResourceTest {
|
|||||||
createTestMembre("Jean", "Dupont"),
|
createTestMembre("Jean", "Dupont"),
|
||||||
createTestMembre("Marie", "Martin")
|
createTestMembre("Marie", "Martin")
|
||||||
);
|
);
|
||||||
when(membreService.listerMembresActifs()).thenReturn(membres);
|
List<MembreDTO> membresDTO = Arrays.asList(
|
||||||
|
createTestMembreDTO("Jean", "Dupont"),
|
||||||
|
createTestMembreDTO("Marie", "Martin")
|
||||||
|
);
|
||||||
|
|
||||||
|
when(membreService.listerMembresActifs(any(Page.class), any(Sort.class))).thenReturn(membres);
|
||||||
|
when(membreService.convertToDTOList(membres)).thenReturn(membresDTO);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
Response response = membreResource.listerMembres();
|
Response response = membreResource.listerMembres(0, 20, "nom", "asc");
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertThat(response.getStatus()).isEqualTo(200);
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
assertThat(response.getEntity()).isEqualTo(membres);
|
assertThat(response.getEntity()).isEqualTo(membresDTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -159,17 +166,22 @@ class MembreResourceTest {
|
|||||||
@DisplayName("Test creerMembre")
|
@DisplayName("Test creerMembre")
|
||||||
void testCreerMembre() {
|
void testCreerMembre() {
|
||||||
// Given
|
// Given
|
||||||
|
MembreDTO membreDTO = createTestMembreDTO("Jean", "Dupont");
|
||||||
Membre membre = createTestMembre("Jean", "Dupont");
|
Membre membre = createTestMembre("Jean", "Dupont");
|
||||||
Membre membreCreated = createTestMembre("Jean", "Dupont");
|
Membre membreCreated = createTestMembre("Jean", "Dupont");
|
||||||
membreCreated.id = 1L;
|
membreCreated.id = 1L;
|
||||||
|
MembreDTO membreCreatedDTO = createTestMembreDTO("Jean", "Dupont");
|
||||||
|
|
||||||
|
when(membreService.convertFromDTO(any(MembreDTO.class))).thenReturn(membre);
|
||||||
when(membreService.creerMembre(any(Membre.class))).thenReturn(membreCreated);
|
when(membreService.creerMembre(any(Membre.class))).thenReturn(membreCreated);
|
||||||
|
when(membreService.convertToDTO(any(Membre.class))).thenReturn(membreCreatedDTO);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
Response response = membreResource.creerMembre(membre);
|
Response response = membreResource.creerMembre(membreDTO);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertThat(response.getStatus()).isEqualTo(201);
|
assertThat(response.getStatus()).isEqualTo(201);
|
||||||
assertThat(response.getEntity()).isEqualTo(membreCreated);
|
assertThat(response.getEntity()).isEqualTo(membreCreatedDTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -177,17 +189,22 @@ class MembreResourceTest {
|
|||||||
void testMettreAJourMembre() {
|
void testMettreAJourMembre() {
|
||||||
// Given
|
// Given
|
||||||
Long id = 1L;
|
Long id = 1L;
|
||||||
|
MembreDTO membreDTO = createTestMembreDTO("Jean", "Dupont");
|
||||||
Membre membre = createTestMembre("Jean", "Dupont");
|
Membre membre = createTestMembre("Jean", "Dupont");
|
||||||
Membre membreUpdated = createTestMembre("Jean", "Martin");
|
Membre membreUpdated = createTestMembre("Jean", "Martin");
|
||||||
membreUpdated.id = id;
|
membreUpdated.id = id;
|
||||||
|
MembreDTO membreUpdatedDTO = createTestMembreDTO("Jean", "Martin");
|
||||||
|
|
||||||
|
when(membreService.convertFromDTO(any(MembreDTO.class))).thenReturn(membre);
|
||||||
when(membreService.mettreAJourMembre(anyLong(), any(Membre.class))).thenReturn(membreUpdated);
|
when(membreService.mettreAJourMembre(anyLong(), any(Membre.class))).thenReturn(membreUpdated);
|
||||||
|
when(membreService.convertToDTO(any(Membre.class))).thenReturn(membreUpdatedDTO);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
Response response = membreResource.mettreAJourMembre(id, membre);
|
Response response = membreResource.mettreAJourMembre(id, membreDTO);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertThat(response.getStatus()).isEqualTo(200);
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
assertThat(response.getEntity()).isEqualTo(membreUpdated);
|
assertThat(response.getEntity()).isEqualTo(membreUpdatedDTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -209,14 +226,16 @@ class MembreResourceTest {
|
|||||||
// Given
|
// Given
|
||||||
String recherche = "Jean";
|
String recherche = "Jean";
|
||||||
List<Membre> membres = Arrays.asList(createTestMembre("Jean", "Dupont"));
|
List<Membre> membres = Arrays.asList(createTestMembre("Jean", "Dupont"));
|
||||||
when(membreService.rechercherMembres(anyString())).thenReturn(membres);
|
List<MembreDTO> membresDTO = Arrays.asList(createTestMembreDTO("Jean", "Dupont"));
|
||||||
|
when(membreService.rechercherMembres(anyString(), any(Page.class), any(Sort.class))).thenReturn(membres);
|
||||||
|
when(membreService.convertToDTOList(membres)).thenReturn(membresDTO);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
Response response = membreResource.rechercherMembres(recherche);
|
Response response = membreResource.rechercherMembres(recherche, 0, 20, "nom", "asc");
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertThat(response.getStatus()).isEqualTo(200);
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
assertThat(response.getEntity()).isEqualTo(membres);
|
assertThat(response.getEntity()).isEqualTo(membresDTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -235,14 +254,29 @@ class MembreResourceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Membre createTestMembre(String prenom, String nom) {
|
private Membre createTestMembre(String prenom, String nom) {
|
||||||
return Membre.builder()
|
Membre membre = new Membre();
|
||||||
.prenom(prenom)
|
membre.setPrenom(prenom);
|
||||||
.nom(nom)
|
membre.setNom(nom);
|
||||||
.email(prenom.toLowerCase() + "." + nom.toLowerCase() + "@test.com")
|
membre.setEmail(prenom.toLowerCase() + "." + nom.toLowerCase() + "@test.com");
|
||||||
.telephone("221701234567")
|
membre.setTelephone("221701234567");
|
||||||
.dateNaissance(LocalDate.of(1990, 1, 1))
|
membre.setDateNaissance(LocalDate.of(1990, 1, 1));
|
||||||
.dateAdhesion(LocalDate.now())
|
membre.setDateAdhesion(LocalDate.now());
|
||||||
.actif(true)
|
membre.setActif(true);
|
||||||
.build();
|
membre.setNumeroMembre("UF-2025-TEST01");
|
||||||
|
return membre;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MembreDTO createTestMembreDTO(String prenom, String nom) {
|
||||||
|
MembreDTO dto = new MembreDTO();
|
||||||
|
dto.setPrenom(prenom);
|
||||||
|
dto.setNom(nom);
|
||||||
|
dto.setEmail(prenom.toLowerCase() + "." + nom.toLowerCase() + "@test.com");
|
||||||
|
dto.setTelephone("221701234567");
|
||||||
|
dto.setDateNaissance(LocalDate.of(1990, 1, 1));
|
||||||
|
dto.setDateAdhesion(LocalDate.now());
|
||||||
|
dto.setStatut("ACTIF");
|
||||||
|
dto.setNumeroMembre("UF-2025-TEST01");
|
||||||
|
dto.setAssociationId(1L);
|
||||||
|
return dto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user