Version propre - Dashboard enhanced

This commit is contained in:
DahoudG
2025-09-13 19:05:06 +00:00
parent 3df010add7
commit 73459b3092
70 changed files with 15317 additions and 1498 deletions

View 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:

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.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 'features/splash/presentation/pages/splash_screen.dart';
import 'features/auth/presentation/pages/login_page.dart';
@@ -12,7 +12,7 @@ class AppWrapper extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
return BlocBuilder<TempAuthBloc, AuthState>(
builder: (context, state) {
switch (state.status) {
case AuthStatus.unknown:

View File

@@ -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();
}
},
);
}
}

View File

@@ -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();
}
},
);
}
}

View File

@@ -21,6 +21,12 @@ import 'package:unionflow_mobile_apps/core/network/auth_interceptor.dart'
as _i772;
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/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'
as _i108;
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>()));
gh.singleton<_i772.AuthInterceptor>(
() => _i772.AuthInterceptor(gh<_i394.SecureTokenStorage>()));
gh.lazySingleton<_i961.CotisationRepository>(
() => _i991.CotisationRepositoryImpl(gh<_i238.ApiService>()));
gh.lazySingleton<_i930.MembreRepository>(
() => _i108.MembreRepositoryImpl(gh<_i238.ApiService>()));
gh.factory<_i41.MembresBloc>(
@@ -57,6 +65,8 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i772.AuthInterceptor>(),
gh<_i978.DioClient>(),
));
gh.factory<_i919.CotisationsBloc>(
() => _i919.CotisationsBloc(gh<_i961.CotisationRepository>()));
gh.singleton<_i635.AuthBloc>(() => _i635.AuthBloc(gh<_i423.AuthService>()));
return this;
}

View 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)';
}
}

View File

@@ -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(),
};

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../models/membre_model.dart';
import '../models/cotisation_model.dart';
import '../models/wave_checkout_session_model.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
// ========================================

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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/models/auth_state.dart';
import '../../../../core/auth/models/login_request.dart';
@@ -100,7 +100,7 @@ class _LoginPageState extends State<LoginPage>
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: BlocConsumer<AuthBloc, AuthState>(
body: BlocConsumer<TempAuthBloc, AuthState>(
listener: (context, state) {
setState(() {
_isLoading = state.status == AuthStatus.checking;
@@ -222,7 +222,7 @@ class _LoginPageState extends State<LoginPage>
rememberMe: _rememberMe,
);
context.read<AuthBloc>().add(AuthLoginRequested(loginRequest));
context.read<TempAuthBloc>().add(AuthLoginRequested(loginRequest));
}
void _showErrorSnackBar(String message) {

View File

@@ -59,14 +59,18 @@ class _LoginFormState extends State<LoginForm>
);
_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>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _fieldAnimationController,
curve: Interval(
index * 0.2,
(index * 0.2) + 0.6,
start,
end,
curve: Curves.easeOut,
),
));
@@ -348,12 +352,15 @@ class _LoginFormState extends State<LoginForm>
: null,
),
const SizedBox(width: 8),
Text(
'Se souvenir de moi',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
Flexible(
child: Text(
'Se souvenir de moi',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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));
}
}
}

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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,
),
),
],
),
),
);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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,
),
),
],
);
}
}

View File

@@ -1,7 +1,24 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.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 {
const DashboardPage({super.key});
@@ -16,11 +33,15 @@ class DashboardPage extends StatelessWidget {
actions: [
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () {},
onPressed: () {
// TODO: Implémenter la navigation vers les notifications
},
),
IconButton(
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Message de bienvenue
_buildWelcomeSection(context),
// 1. ACCUEIL & CONTEXTE - Message de bienvenue personnalisé
const WelcomeSectionWidget(),
const SizedBox(height: 24),
// Cartes KPI principales
_buildKPICards(context),
// 2. VISION GLOBALE - Indicateurs clés de performance (KPI)
// Vue d'ensemble immédiate de la santé de l'association
const KPICardsWidget(),
const SizedBox(height: 24),
// Graphiques et statistiques
_buildChartsSection(context),
// 3. ACTIONS PRIORITAIRES - Actions rapides et gestion
// Accès direct aux tâches critiques quotidiennes
const QuickActionsWidget(),
const SizedBox(height: 24),
// Actions rapides
_buildQuickActions(context),
// 4. SUIVI TEMPS RÉEL - Flux d'activités en direct
// Monitoring des événements récents et alertes
const RecentActivitiesWidget(),
const SizedBox(height: 24),
// Activités récentes
_buildRecentActivities(context),
// 5. ANALYSES APPROFONDIES - Graphiques et tendances
// 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,
),
),
],
),
);
}
}

View File

@@ -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,
),
);
}
}

View File

@@ -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,
),
],
),
),
);
}
}

View File

@@ -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),
),
),
],
),
],
);
}
}

View File

@@ -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,
),
],
),
],
),
);
}
}

View File

@@ -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',
),
],
),
),
],
);
}
}

View File

@@ -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,
),
),
],
),
),
),
),
],
),
);
}
}

View File

@@ -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,
),
);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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)',
],
),
],
);
}
}

View File

@@ -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,
),
),
],
),
);
}
}

View File

@@ -69,6 +69,9 @@ class MembreDetailLoaded extends MembresState {
List<Object?> get props => [membre];
}
/// Alias pour MembreDetailLoaded pour compatibilité
typedef MembreLoaded = MembreDetailLoaded;
/// État de succès pour les statistiques
class MembresStatsLoaded extends MembresState {
const MembresStatsLoaded(this.stats);

View File

@@ -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'),
),
],
),
);
}
}

View File

@@ -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;
}
}

View File

@@ -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,
),
],
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/coming_soon_page.dart';
import '../bloc/membres_bloc.dart';
@@ -9,6 +10,12 @@ import '../bloc/membres_event.dart';
import '../bloc/membres_state.dart';
import '../widgets/membre_card.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
@@ -23,6 +30,7 @@ class _MembresListPageState extends State<MembresListPage> {
final RefreshController _refreshController = RefreshController();
final TextEditingController _searchController = TextEditingController();
late MembresBloc _membresBloc;
List<MembreModel> _membres = [];
@override
void initState() {
@@ -56,6 +64,16 @@ class _MembresListPageState extends State<MembresListPage> {
foregroundColor: Colors.white,
elevation: 0,
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(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => _showAddMemberDialog(),
@@ -98,6 +116,13 @@ class _MembresListPageState extends State<MembresListPage> {
_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
if (state is! MembresRefreshing && state is! MembresLoading) {
_refreshController.refreshCompleted();
@@ -288,30 +313,28 @@ class _MembresListPageState extends State<MembresListPage> {
/// Affiche les détails d'un membre
void _showMemberDetails(membre) {
// TODO: Implémenter la page de détails
showDialog(
context: context,
builder: (context) => const ComingSoonPage(
title: 'Détails du membre',
description: 'La page de détails du membre sera bientôt disponible.',
icon: Icons.person,
color: AppTheme.primaryColor,
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MembreDetailsPage(
membreId: membre.id,
membre: membre,
),
),
);
}
/// Affiche le dialog d'ajout de membre
void _showAddMemberDialog() {
// TODO: Implémenter le formulaire d'ajout
showDialog(
context: context,
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,
/// Affiche le formulaire d'ajout de membre
void _showAddMemberDialog() async {
final result = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const MembreCreatePage(),
),
);
// 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
@@ -329,29 +352,59 @@ class _MembresListPageState extends State<MembresListPage> {
}
/// Affiche la confirmation de suppression
void _showDeleteConfirmation(membre) {
// TODO: Implémenter la confirmation de suppression
showDialog(
void _showDeleteConfirmation(membre) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => const ComingSoonPage(
title: 'Supprimer le membre',
description: 'La confirmation de suppression sera bientôt disponible.',
icon: Icons.delete,
color: AppTheme.errorColor,
),
barrierDismissible: false,
builder: (context) => MembreDeleteDialog(membre: membre),
);
// Si le membre a été supprimé/désactivé avec succès, recharger la liste
if (result == true) {
_membresBloc.add(const RefreshMembres());
}
}
/// Affiche les statistiques
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(
context: context,
builder: (context) => const ComingSoonPage(
title: 'Statistiques',
description: 'Les statistiques des membres seront bientôt disponibles.',
icon: Icons.analytics,
color: AppTheme.infoColor,
builder: (context) => MembresExportDialog(
membres: _membres,
),
);
}

View File

@@ -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,
),
),
],
),
),
),
],
);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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,
),
);
}
}

View File

@@ -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);
}
}

View File

@@ -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,
),
);
}
}
}
}

View File

@@ -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' : ''}';
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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é
},
),
),
);
}
}

View File

@@ -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;
}

View File

@@ -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,
);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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),
),
],
),
),
),
),
],
);
}
}

View File

@@ -3,8 +3,9 @@ import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/coming_soon_page.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 '../../../cotisations/presentation/pages/cotisations_list_page.dart';
import '../widgets/custom_bottom_nav_bar.dart';
class MainNavigation extends StatefulWidget {
@@ -85,9 +86,7 @@ class _MainNavigationState extends State<MainNavigation>
body: IndexedStack(
index: _currentIndex,
children: [
EnhancedDashboard(
onNavigateToTab: _onTabTapped,
),
const DashboardPage(),
_buildMembresPage(),
_buildCotisationsPage(),
_buildEventsPage(),
@@ -209,20 +208,7 @@ class _MainNavigationState extends State<MainNavigation>
}
Widget _buildCotisationsPage() {
return const ComingSoonPage(
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',
],
);
return const CotisationsListPage();
}
Widget _buildEventsPage() {

View File

@@ -88,15 +88,15 @@ class _SplashScreenState extends State<SplashScreen>
),
);
// Séquence d'animations
// Séquence d'animations avec vérification mounted
await Future.delayed(const Duration(milliseconds: 300));
_logoController.forward();
if (mounted) _logoController.forward();
await Future.delayed(const Duration(milliseconds: 500));
_textController.forward();
if (mounted) _textController.forward();
await Future.delayed(const Duration(milliseconds: 300));
_progressController.forward();
if (mounted) _progressController.forward();
// Attendre la fin de toutes les animations + temps de chargement
await Future.delayed(const Duration(milliseconds: 2000));

View File

@@ -1,17 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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/auth_event.dart';
import 'core/auth/services/temp_auth_service.dart';
import 'core/di/injection.dart';
import 'shared/theme/app_theme.dart';
import 'app_temp.dart';
import 'app.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialisation des données de localisation
await initializeDateFormatting('fr_FR', null);
// Configuration de l'injection de dépendances
await configureDependencies();
@@ -19,7 +23,7 @@ void main() async {
await _configureApp();
// Lancement de l'application
runApp(const UnionFlowTempApp());
runApp(const UnionFlowApp());
}
/// Configure les paramètres globaux de l'application
@@ -41,9 +45,9 @@ Future<void> _configureApp() async {
);
}
/// Application principale temporaire
class UnionFlowTempApp extends StatelessWidget {
const UnionFlowTempApp({super.key});
/// Application principale
class UnionFlowApp extends StatelessWidget {
const UnionFlowApp({super.key});
@override
Widget build(BuildContext context) {
@@ -67,13 +71,13 @@ class UnionFlowTempApp extends StatelessWidget {
locale: const Locale('fr', 'FR'),
// Application principale
home: const AppTempWrapper(),
home: const AppWrapper(),
// Builder global pour gérer les erreurs
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(1.0),
textScaler: const TextScaler.linear(1.0),
),
child: child ?? const SizedBox(),
);

View File

@@ -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(),
);
},
),
);
}
}

View File

@@ -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(),
);
},
),
);
}
}

View 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;
}
}

View File

@@ -22,11 +22,17 @@ class ComingSoonPage extends StatelessWidget {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: SafeArea(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
child: ConstrainedBox(
constraints: BoxConstraints(
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
Container(
width: 120,
@@ -205,6 +211,7 @@ class ComingSoonPage extends StatelessWidget {
textAlign: TextAlign.center,
),
],
),
),
),
),

View File

@@ -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();
}
}

View File

@@ -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
);
}
}

View File

@@ -2,8 +2,11 @@ package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.Membre;
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.time.LocalDate;
import java.util.List;
import java.util.Optional;
@@ -48,4 +51,77 @@ public class MembreRepository implements PanacheRepository<Membre> {
return find("lower(nom) like ?1 or lower(prenom) like ?1",
"%" + 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();
}
}

View File

@@ -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();
}
}
}

View File

@@ -1,7 +1,10 @@
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.service.MembreService;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
@@ -35,10 +38,21 @@ public class MembreResource {
@GET
@Operation(summary = "Lister tous les membres actifs")
@APIResponse(responseCode = "200", description = "Liste des membres actifs")
public Response listerMembres() {
LOG.info("Récupération de la liste des membres actifs");
List<Membre> membres = membreService.listerMembresActifs();
return Response.ok(membres).build();
public Response listerMembres(
@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("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
@@ -49,7 +63,10 @@ public class MembreResource {
public Response obtenirMembre(@Parameter(description = "ID du membre") @PathParam("id") Long id) {
LOG.infof("Récupération du membre ID: %d", 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)
.entity(Map.of("message", "Membre non trouvé")).build());
}
@@ -58,11 +75,25 @@ public class MembreResource {
@Operation(summary = "Créer un nouveau membre")
@APIResponse(responseCode = "201", description = "Membre créé avec succès")
@APIResponse(responseCode = "400", description = "Données invalides")
public Response creerMembre(@Valid Membre membre) {
LOG.infof("Création d'un nouveau membre: %s", membre.getEmail());
public Response creerMembre(@Valid MembreDTO membreDTO) {
LOG.infof("Création d'un nouveau membre: %s", membreDTO.getEmail());
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);
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) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("message", e.getMessage())).build();
@@ -76,11 +107,25 @@ public class MembreResource {
@APIResponse(responseCode = "404", description = "Membre non trouvé")
@APIResponse(responseCode = "400", description = "Données invalides")
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);
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);
return Response.ok(membreMisAJour).build();
// Conversion de retour vers DTO
MembreDTO membreMisAJourDTO = membreService.convertToDTO(membreMisAJour);
return Response.ok(membreMisAJourDTO).build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("message", e.getMessage())).build();
@@ -107,26 +152,74 @@ public class MembreResource {
@Path("/recherche")
@Operation(summary = "Rechercher des membres par nom ou prénom")
@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);
if (recherche == null || recherche.trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.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
@Path("/stats")
@Operation(summary = "Obtenir les statistiques des membres")
@APIResponse(responseCode = "200", description = "Statistiques des membres")
@Operation(summary = "Obtenir les statistiques avancées des membres")
@APIResponse(responseCode = "200", description = "Statistiques complètes des membres")
public Response obtenirStatistiques() {
LOG.info("Récupération des statistiques des membres");
long nombreMembresActifs = membreService.compterMembresActifs();
return Response.ok(Map.of(
"nombreMembresActifs", nombreMembresActifs,
"timestamp", java.time.LocalDateTime.now()
)).build();
LOG.info("Récupération des statistiques avancées des membres");
Map<String, Object> statistiques = membreService.obtenirStatistiquesAvancees();
return Response.ok(statistiques).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();
}
}
}

View File

@@ -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û");
}
}
}

View File

@@ -1,16 +1,22 @@
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.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 org.jboss.logging.Logger;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Service métier pour les membres
@@ -140,4 +146,153 @@ public class MembreService {
public long compterMembresActifs() {
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);
}
}

View File

@@ -71,9 +71,12 @@ quarkus:
level: INFO
format: "%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{2.}] (%t) %s%e%n"
category:
"dev.lions.unionflow": INFO
"org.hibernate": WARN
"io.quarkus": INFO
"dev.lions.unionflow":
level: INFO
"org.hibernate":
level: WARN
"io.quarkus":
level: INFO
---
# Profil de développement
@@ -94,8 +97,10 @@ quarkus:
migrate-at-start: false
log:
category:
"dev.lions.unionflow": DEBUG
"org.hibernate.SQL": DEBUG
"dev.lions.unionflow":
level: DEBUG
"org.hibernate.SQL":
level: DEBUG
---
# Profil de test
@@ -126,5 +131,7 @@ quarkus:
console:
level: WARN
category:
"dev.lions.unionflow": INFO
root: WARN
"dev.lions.unionflow":
level: INFO
root:
level: WARN

View 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 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());

View 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;

View File

@@ -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);
}
}

View File

@@ -1,7 +1,10 @@
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.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.DisplayName;
import org.junit.jupiter.api.Test;
@@ -17,9 +20,7 @@ import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
/**
@@ -115,14 +116,20 @@ class MembreResourceTest {
createTestMembre("Jean", "Dupont"),
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
Response response = membreResource.listerMembres();
Response response = membreResource.listerMembres(0, 20, "nom", "asc");
// Then
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getEntity()).isEqualTo(membres);
assertThat(response.getEntity()).isEqualTo(membresDTO);
}
@Test
@@ -159,17 +166,22 @@ class MembreResourceTest {
@DisplayName("Test creerMembre")
void testCreerMembre() {
// Given
MembreDTO membreDTO = createTestMembreDTO("Jean", "Dupont");
Membre membre = createTestMembre("Jean", "Dupont");
Membre membreCreated = createTestMembre("Jean", "Dupont");
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.convertToDTO(any(Membre.class))).thenReturn(membreCreatedDTO);
// When
Response response = membreResource.creerMembre(membre);
Response response = membreResource.creerMembre(membreDTO);
// Then
assertThat(response.getStatus()).isEqualTo(201);
assertThat(response.getEntity()).isEqualTo(membreCreated);
assertThat(response.getEntity()).isEqualTo(membreCreatedDTO);
}
@Test
@@ -177,17 +189,22 @@ class MembreResourceTest {
void testMettreAJourMembre() {
// Given
Long id = 1L;
MembreDTO membreDTO = createTestMembreDTO("Jean", "Dupont");
Membre membre = createTestMembre("Jean", "Dupont");
Membre membreUpdated = createTestMembre("Jean", "Martin");
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.convertToDTO(any(Membre.class))).thenReturn(membreUpdatedDTO);
// When
Response response = membreResource.mettreAJourMembre(id, membre);
Response response = membreResource.mettreAJourMembre(id, membreDTO);
// Then
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getEntity()).isEqualTo(membreUpdated);
assertThat(response.getEntity()).isEqualTo(membreUpdatedDTO);
}
@Test
@@ -209,14 +226,16 @@ class MembreResourceTest {
// Given
String recherche = "Jean";
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
Response response = membreResource.rechercherMembres(recherche);
Response response = membreResource.rechercherMembres(recherche, 0, 20, "nom", "asc");
// Then
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getEntity()).isEqualTo(membres);
assertThat(response.getEntity()).isEqualTo(membresDTO);
}
@Test
@@ -235,14 +254,29 @@ class MembreResourceTest {
}
private Membre createTestMembre(String prenom, String nom) {
return Membre.builder()
.prenom(prenom)
.nom(nom)
.email(prenom.toLowerCase() + "." + nom.toLowerCase() + "@test.com")
.telephone("221701234567")
.dateNaissance(LocalDate.of(1990, 1, 1))
.dateAdhesion(LocalDate.now())
.actif(true)
.build();
Membre membre = new Membre();
membre.setPrenom(prenom);
membre.setNom(nom);
membre.setEmail(prenom.toLowerCase() + "." + nom.toLowerCase() + "@test.com");
membre.setTelephone("221701234567");
membre.setDateNaissance(LocalDate.of(1990, 1, 1));
membre.setDateAdhesion(LocalDate.now());
membre.setActif(true);
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;
}
}