feat: WebSocket temps réel + Finance Workflow + corrections

- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics)
  * Backend: KafkaEventProducer, KafkaEventConsumer
  * Mobile: WebSocketService (reconnection, heartbeat, typed events)
  * DashboardBloc: Auto-refresh depuis WebSocket events

- Finance Workflow: approbations + budgets (backend + mobile)
  * Backend: entities, services, resources, migrations Flyway V6
  * Mobile: features finance_workflow complète avec BLoC

- Corrections DI: interfaces IRepository partout
  * IProfileRepository, IOrganizationRepository, IMembreRepository
  * GetIt configuré avec @injectable

- Spec-Kit: constitution + templates mis à jour
  * .specify/memory/constitution.md enrichie
  * Templates agent, plan, spec, tasks, checklist

- Nettoyage: fichiers temporaires supprimés

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 02:12:17 +00:00
parent bbc409de9d
commit e8ad874015
635 changed files with 58160 additions and 20674 deletions

View File

@@ -2,17 +2,43 @@
library contributions_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../../core/utils/logger.dart';
import '../data/models/contribution_model.dart';
import '../data/repositories/contribution_repository.dart';
import '../data/repositories/contribution_repository.dart' show ContributionPageResult;
import '../domain/usecases/get_contributions.dart';
import '../domain/usecases/get_contribution_by_id.dart';
import '../domain/usecases/create_contribution.dart' as uc;
import '../domain/usecases/update_contribution.dart' as uc;
import '../domain/usecases/delete_contribution.dart' as uc;
import '../domain/usecases/pay_contribution.dart';
import '../domain/usecases/get_contribution_stats.dart';
import '../domain/repositories/contribution_repository.dart';
import 'contributions_event.dart';
import 'contributions_state.dart';
/// BLoC pour gérer l'état des contributions via l'API backend
/// BLoC pour gérer l'état des contributions via les use cases (Clean Architecture)
@injectable
class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
final ContributionRepository _repository;
final GetContributions _getContributions;
final GetContributionById _getContributionById;
final uc.CreateContribution _createContribution;
final uc.UpdateContribution _updateContribution;
final uc.DeleteContribution _deleteContribution;
final PayContribution _payContribution;
final GetContributionStats _getContributionStats;
final IContributionRepository _repository; // Pour méthodes non-couvertes par use cases
ContributionsBloc(this._repository) : super(const ContributionsInitial()) {
ContributionsBloc(
this._getContributions,
this._getContributionById,
this._createContribution,
this._updateContribution,
this._deleteContribution,
this._payContribution,
this._getContributionStats,
this._repository,
) : super(const ContributionsInitial()) {
on<LoadContributions>(_onLoadContributions);
on<LoadContributionById>(_onLoadContributionById);
on<CreateContribution>(_onCreateContribution);
@@ -41,10 +67,8 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
emit(const ContributionsLoading(message: 'Chargement des contributions...'));
final result = await _repository.getCotisations(
page: event.page,
size: event.size,
);
// Use case: Get contributions
final result = await _getContributions(page: event.page, size: event.size);
emit(ContributionsLoaded(
contributions: result.contributions,
@@ -70,7 +94,7 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Chargement de la contribution...'));
final contribution = await _repository.getCotisationById(event.id);
final contribution = await _getContributionById(event.id);
emit(ContributionDetailLoaded(contribution: contribution));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -84,7 +108,7 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Création de la contribution...'));
final created = await _repository.createCotisation(event.contribution);
final created = await _createContribution(event.contribution);
emit(ContributionCreated(contribution: created));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -98,7 +122,7 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Mise à jour de la contribution...'));
final updated = await _repository.updateCotisation(event.id, event.contribution);
final updated = await _updateContribution(event.id, event.contribution);
emit(ContributionUpdated(contribution: updated));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -112,7 +136,7 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Suppression de la contribution...'));
await _repository.deleteCotisation(event.id);
await _deleteContribution(event.id);
emit(ContributionDeleted(id: event.id));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -181,19 +205,14 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Chargement des contributions payées...'));
final result = await _repository.getCotisations(
page: event.page,
size: event.size,
statut: 'PAYEE',
);
final result = await _repository.getMesCotisations();
final payees = result.contributions.where((c) => c.statut == ContributionStatus.payee).toList();
emit(ContributionsLoaded(
contributions: result.contributions,
total: result.total,
page: result.page,
size: result.size,
totalPages: result.totalPages,
contributions: payees,
total: payees.length,
page: 0,
size: payees.length,
totalPages: payees.isEmpty ? 0 : 1,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -207,19 +226,14 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Chargement des contributions non payées...'));
final result = await _repository.getCotisations(
page: event.page,
size: event.size,
statut: 'NON_PAYEE',
);
final result = await _repository.getMesCotisations();
final nonPayees = result.contributions.where((c) => c.statut != ContributionStatus.payee).toList();
emit(ContributionsLoaded(
contributions: result.contributions,
total: result.total,
page: result.page,
size: result.size,
totalPages: result.totalPages,
contributions: nonPayees,
total: nonPayees.length,
page: 0,
size: nonPayees.length,
totalPages: nonPayees.isEmpty ? 0 : 1,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -233,19 +247,14 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
) async {
try {
emit(const ContributionsLoading(message: 'Chargement des contributions en retard...'));
final result = await _repository.getCotisations(
page: event.page,
size: event.size,
statut: 'EN_RETARD',
);
final result = await _repository.getMesCotisations();
final enRetard = result.contributions.where((c) => c.statut == ContributionStatus.enRetard || c.estEnRetard).toList();
emit(ContributionsLoaded(
contributions: result.contributions,
total: result.total,
page: result.page,
size: result.size,
totalPages: result.totalPages,
contributions: enRetard,
total: enRetard.length,
page: 0,
size: enRetard.length,
totalPages: enRetard.isEmpty ? 0 : 1,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
@@ -260,8 +269,8 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
try {
emit(const ContributionsLoading(message: 'Enregistrement du paiement...'));
final updated = await _repository.enregistrerPaiement(
event.contributionId,
final updated = await _payContribution(
cotisationId: event.contributionId,
montant: event.montant,
datePaiement: event.datePaiement,
methodePaiement: event.methodePaiement.name,
@@ -280,16 +289,54 @@ class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
LoadContributionsStats event,
Emitter<ContributionsState> emit,
) async {
List<ContributionModel>? preservedList = state is ContributionsLoaded ? (state as ContributionsLoaded).contributions : null;
try {
emit(const ContributionsLoading(message: 'Chargement des statistiques...'));
// Charger synthèse + liste pour que la page « Mes statistiques » ait toujours donut et prochaines échéances
final mesSynthese = await _getContributionStats();
final listResult = preservedList == null ? await _getContributions() : null;
final contributions = preservedList ?? listResult?.contributions;
if (mesSynthese != null && mesSynthese.isNotEmpty) {
final normalized = _normalizeSyntheseForStats(mesSynthese);
emit(ContributionsStatsLoaded(stats: normalized, contributions: contributions));
return;
}
final stats = await _repository.getStatistiques();
emit(ContributionsStatsLoaded(stats: stats.map((k, v) => MapEntry(k, (v is num) ? v.toDouble() : 0.0))));
emit(ContributionsStatsLoaded(
stats: stats.map((k, v) => MapEntry(k, v is num ? v.toDouble() : (v is int ? v.toDouble() : 0.0))),
contributions: contributions,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur', error: e));
}
}
/// Normalise la réponse synthese (mes) pour l'affichage stats (clés numériques + isMesSynthese).
Map<String, dynamic> _normalizeSyntheseForStats(Map<String, dynamic> s) {
final montantDu = _toDouble(s['montantDu']);
final totalPayeAnnee = _toDouble(s['totalPayeAnnee']);
final totalAnnee = montantDu + totalPayeAnnee;
final taux = totalAnnee > 0 ? (totalPayeAnnee / totalAnnee * 100) : 0.0;
return {
'isMesSynthese': true,
'cotisationsEnAttente': (s['cotisationsEnAttente'] is int) ? s['cotisationsEnAttente'] as int : ((s['cotisationsEnAttente'] as num?)?.toInt() ?? 0),
'montantDu': montantDu,
'totalPayeAnnee': totalPayeAnnee,
'totalMontant': totalAnnee,
'tauxPaiement': taux,
'prochaineEcheance': s['prochaineEcheance']?.toString(),
'anneeEnCours': s['anneeEnCours'] is int ? s['anneeEnCours'] as int : ((s['anneeEnCours'] as num?)?.toInt() ?? DateTime.now().year),
};
}
double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
Future<void> _onGenerateAnnualContributions(
GenerateAnnualContributions event,
Emitter<ContributionsState> emit,

View File

@@ -102,14 +102,16 @@ class PaymentRecorded extends ContributionsState {
List<Object?> get props => [contribution];
}
/// État statistiques chargées
/// État statistiques chargées (liste optionnelle conservée pour ne pas perdre l'onglet Toutes au retour)
class ContributionsStatsLoaded extends ContributionsState {
final Map<String, dynamic> stats;
/// Liste des contributions conservée depuis l'état précédent (ex: au retour de la page Stats).
final List<ContributionModel>? contributions;
const ContributionsStatsLoaded({required this.stats});
const ContributionsStatsLoaded({required this.stats, this.contributions});
@override
List<Object?> get props => [stats];
List<Object?> get props => [stats, contributions];
}
/// État contributions générées

View File

@@ -12,6 +12,8 @@ enum ContributionStatus {
payee,
@JsonValue('NON_PAYEE')
nonPayee,
@JsonValue('EN_ATTENTE')
enAttente,
@JsonValue('EN_RETARD')
enRetard,
@JsonValue('PARTIELLE')
@@ -56,6 +58,23 @@ enum PaymentMethod {
autre,
}
/// Extension pour obtenir le code API d'une méthode de paiement (ex: pour icônes assets).
extension PaymentMethodCode on PaymentMethod {
String get code {
switch (this) {
case PaymentMethod.especes: return 'ESPECES';
case PaymentMethod.cheque: return 'CHEQUE';
case PaymentMethod.virement: return 'VIREMENT';
case PaymentMethod.carteBancaire: return 'CARTE_BANCAIRE';
case PaymentMethod.waveMoney: return 'WAVE_MONEY';
case PaymentMethod.orangeMoney: return 'ORANGE_MONEY';
case PaymentMethod.freeMoney: return 'FREE_MONEY';
case PaymentMethod.mobileMoney: return 'MOBILE_MONEY';
case PaymentMethod.autre: return 'AUTRE';
}
}
}
/// Modèle complet d'une contribution
@JsonSerializable(explicitToJson: true)
class ContributionModel extends Equatable {

View File

@@ -93,6 +93,7 @@ const _$ContributionTypeEnumMap = {
const _$ContributionStatusEnumMap = {
ContributionStatus.payee: 'PAYEE',
ContributionStatus.nonPayee: 'NON_PAYEE',
ContributionStatus.enAttente: 'EN_ATTENTE',
ContributionStatus.enRetard: 'EN_RETARD',
ContributionStatus.partielle: 'PARTIELLE',
ContributionStatus.annulee: 'ANNULEE',

View File

@@ -1,17 +1,109 @@
/// Repository pour la gestion des cotisations via l'API backend
library contribution_repository;
/// Implémentation du repository des cotisations via l'API backend
library contribution_repository_impl;
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import 'package:unionflow_mobile_apps/core/utils/logger.dart';
import '../../domain/repositories/contribution_repository.dart';
import '../models/contribution_model.dart';
/// Repository des cotisations - appels API réels vers /api/cotisations
class ContributionRepository {
final Dio _dio;
/// Implémentation du repository des cotisations - appels API réels vers /api/cotisations
@LazySingleton(as: IContributionRepository)
class ContributionRepositoryImpl implements IContributionRepository {
final ApiClient _apiClient;
static const String _baseUrl = '/api/cotisations';
ContributionRepository(this._dio);
ContributionRepositoryImpl(this._apiClient);
/// Récupère la liste des cotisations avec pagination
/// Toutes les cotisations du membre connecté (GET /api/cotisations/mes-cotisations).
Future<ContributionPageResult> getMesCotisations({int page = 0, int size = 50}) async {
final response = await _apiClient.get(
'$_baseUrl/mes-cotisations',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode != 200) {
throw Exception(
'Erreur lors de la récupération des cotisations: ${response.statusCode}',
);
}
final data = response.data;
final List<dynamic> list = data is List ? data as List<dynamic> : <dynamic>[];
final contributions = list.map((e) => _summaryToModel(e as Map<String, dynamic>)).toList();
return ContributionPageResult(
contributions: contributions,
total: contributions.length,
page: page,
size: size,
totalPages: list.isEmpty ? 0 : 1,
);
}
/// Récupère les cotisations en attente du membre connecté (endpoint dédié).
Future<ContributionPageResult> getMesCotisationsEnAttente() async {
final path = '$_baseUrl/mes-cotisations/en-attente';
final response = await _apiClient.get(path);
if (response.statusCode != 200) {
throw Exception(
'Erreur lors de la récupération des cotisations: ${response.statusCode}',
);
}
final data = response.data;
final List<dynamic> list = data is List ? data : (data is Map ? (data['data'] ?? data['content'] ?? []) as List<dynamic>? ?? [] : []);
final contributions = list
.map((e) => _summaryToModel(e as Map<String, dynamic>))
.toList();
return ContributionPageResult(
contributions: contributions,
total: contributions.length,
page: 0,
size: contributions.length,
totalPages: contributions.isEmpty ? 0 : 1,
);
}
static ContributionModel _summaryToModel(Map<String, dynamic> json) {
final id = json['id']?.toString();
final statutStr = json['statut'] as String? ?? 'EN_ATTENTE';
final statut = _mapStatut(statutStr);
final montantDu = (json['montantDu'] as num?)?.toDouble() ?? 0.0;
final montantPaye = (json['montantPaye'] as num?)?.toDouble();
final dateEcheanceStr = json['dateEcheance'] as String?;
final dateEcheance = dateEcheanceStr != null
? DateTime.tryParse(dateEcheanceStr) ?? DateTime.now()
: DateTime.now();
final annee = (json['annee'] as num?)?.toInt() ?? dateEcheance.year;
return ContributionModel(
id: id,
membreId: '', // membre implicite (endpoint "mes cotisations")
membreNom: (json['nomMembre'] ?? json['nomCompletMembre']) as String?,
type: ContributionType.annuelle,
statut: statut,
montant: montantDu,
montantPaye: montantPaye,
devise: 'XOF',
dateEcheance: dateEcheance,
annee: annee,
);
}
static ContributionStatus _mapStatut(String code) {
switch (code.toUpperCase()) {
case 'PAYEE':
return ContributionStatus.payee;
case 'EN_RETARD':
return ContributionStatus.enRetard;
case 'PARTIELLE':
return ContributionStatus.partielle;
case 'ANNULEE':
return ContributionStatus.annulee;
case 'EN_ATTENTE':
case 'NON_PAYEE':
default:
return ContributionStatus.nonPayee;
}
}
/// Récupère la liste des cotisations avec pagination (toutes cotisations, nécessite droits admin)
Future<ContributionPageResult> getCotisations({
int page = 0,
int size = 20,
@@ -29,7 +121,7 @@ class ContributionRepository {
if (type != null) queryParams['type'] = type;
if (annee != null) queryParams['annee'] = annee;
final response = await _dio.get(
final response = await _apiClient.get(
_baseUrl,
queryParameters: queryParams,
);
@@ -66,25 +158,96 @@ class ContributionRepository {
/// Récupère une cotisation par ID
Future<ContributionModel> getCotisationById(String id) async {
final response = await _dio.get('$_baseUrl/$id');
final response = await _apiClient.get('$_baseUrl/$id');
if (response.statusCode == 200) {
return ContributionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Cotisation non trouvée');
}
/// Crée une nouvelle cotisation
/// Crée une nouvelle cotisation (payload conforme au backend CreateCotisationRequest)
Future<ContributionModel> createCotisation(ContributionModel contribution) async {
final response = await _dio.post(_baseUrl, data: contribution.toJson());
final body = _toCreateCotisationRequest(contribution);
final response = await _apiClient.post(_baseUrl, data: body);
if (response.statusCode == 201 || response.statusCode == 200) {
return ContributionModel.fromJson(response.data as Map<String, dynamic>);
final data = Map<String, dynamic>.from(response.data as Map<String, dynamic>);
_normalizeCotisationResponse(data);
return ContributionModel.fromJson(data);
}
throw Exception('Erreur lors de la création: ${response.statusCode}');
final message = response.data is Map
? (response.data as Map)['error'] ?? response.data.toString()
: response.data?.toString() ?? 'Erreur ${response.statusCode}';
throw Exception('Erreur lors de la création: $message');
}
/// Construit le body attendu par POST /api/cotisations (CreateCotisationRequest)
static Map<String, dynamic> _toCreateCotisationRequest(ContributionModel c) {
if (c.organisationId == null || c.organisationId!.trim().isEmpty) {
throw Exception('L\'organisation du membre est requise pour créer une cotisation.');
}
final typeStr = _contributionTypeToBackend(c.type);
final dateStr = _formatLocalDate(c.dateEcheance);
final desc = c.description?.trim();
final libelle = desc != null && desc.isNotEmpty
? (desc.length > 100 ? desc.substring(0, 100) : desc)
: 'Cotisation $typeStr ${c.annee}';
final description = desc != null && desc.isNotEmpty
? (desc.length > 500 ? desc.substring(0, 500) : desc)
: null;
return {
'membreId': c.membreId,
'organisationId': c.organisationId!.trim(),
'typeCotisation': typeStr,
'libelle': libelle,
if (description != null) 'description': description,
'montantDu': c.montant,
'codeDevise': c.devise.length == 3 ? c.devise : 'XOF',
'dateEcheance': dateStr,
'periode': '${_monthName(c.dateEcheance.month)} ${c.dateEcheance.year}',
'annee': c.annee,
'mois': c.mois ?? c.dateEcheance.month,
'recurrente': false,
if (c.notes != null && c.notes!.isNotEmpty) 'observations': c.notes,
};
}
static String _contributionTypeToBackend(ContributionType t) {
switch (t) {
case ContributionType.mensuelle:
return 'MENSUELLE';
case ContributionType.trimestrielle:
return 'TRIMESTRIELLE';
case ContributionType.semestrielle:
return 'SEMESTRIELLE';
case ContributionType.annuelle:
return 'ANNUELLE';
case ContributionType.exceptionnelle:
return 'EXCEPTIONNELLE';
}
}
static String _formatLocalDate(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
static String _monthName(int month) {
const names = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
return month >= 1 && month <= 12 ? names[month - 1] : 'Mois $month';
}
/// Adapte les clés de la réponse backend (CotisationResponse) vers le modèle mobile
static void _normalizeCotisationResponse(Map<String, dynamic> data) {
if (data.containsKey('nomMembre') && !data.containsKey('membreNom')) data['membreNom'] = data['nomMembre'];
if (data.containsKey('nomOrganisation') && !data.containsKey('organisationNom')) data['organisationNom'] = data['nomOrganisation'];
if (data.containsKey('codeDevise') && !data.containsKey('devise')) data['devise'] = data['codeDevise'];
if (data.containsKey('montantDu') && !data.containsKey('montant')) data['montant'] = data['montantDu'];
if (data['id'] != null && data['id'] is! String) data['id'] = data['id'].toString();
if (data['membreId'] != null && data['membreId'] is! String) data['membreId'] = data['membreId'].toString();
if (data['organisationId'] != null && data['organisationId'] is! String) data['organisationId'] = data['organisationId'].toString();
}
/// Met à jour une cotisation
Future<ContributionModel> updateCotisation(String id, ContributionModel contribution) async {
final response = await _dio.put('$_baseUrl/$id', data: contribution.toJson());
final response = await _apiClient.put('$_baseUrl/$id', data: contribution.toJson());
if (response.statusCode == 200) {
return ContributionModel.fromJson(response.data as Map<String, dynamic>);
}
@@ -93,12 +256,46 @@ class ContributionRepository {
/// Supprime une cotisation
Future<void> deleteCotisation(String id) async {
final response = await _dio.delete('$_baseUrl/$id');
final response = await _apiClient.delete('$_baseUrl/$id');
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception('Erreur lors de la suppression: ${response.statusCode}');
}
}
/// Initie un paiement en ligne (Wave Checkout API).
/// Retourne l'URL à ouvrir (wave_launch_url) pour que le membre confirme dans l'app Wave.
/// Spec: https://docs.wave.com/checkout
Future<WavePaiementInitResult> initierPaiementEnLigne({
required String cotisationId,
required String methodePaiement,
required String numeroTelephone,
}) async {
final response = await _apiClient.post(
'/api/paiements/initier-paiement-en-ligne',
data: {
'cotisationId': cotisationId,
'methodePaiement': methodePaiement,
'numeroTelephone': numeroTelephone.replaceAll(RegExp(r'\D'), ''),
},
);
if (response.statusCode != 201 && response.statusCode != 200) {
final msg = response.data is Map
? (response.data['message'] ?? response.data['error'] ?? response.statusCode)
: response.statusCode;
throw Exception('Impossible d\'initier le paiement: $msg');
}
final data = response.data is Map<String, dynamic>
? response.data as Map<String, dynamic>
: Map<String, dynamic>.from(response.data as Map);
return WavePaiementInitResult(
redirectUrl: data['redirectUrl'] as String? ?? data['waveLaunchUrl'] as String? ?? '',
waveLaunchUrl: data['waveLaunchUrl'] as String? ?? data['redirectUrl'] as String? ?? '',
waveCheckoutSessionId: data['waveCheckoutSessionId'] as String?,
clientReference: data['clientReference'] as String?,
message: data['message'] as String? ?? 'Ouvrez Wave pour confirmer le paiement.',
);
}
/// Enregistre un paiement
Future<ContributionModel> enregistrerPaiement(
String cotisationId, {
@@ -108,7 +305,7 @@ class ContributionRepository {
String? numeroPaiement,
String? referencePaiement,
}) async {
final response = await _dio.post(
final response = await _apiClient.post(
'$_baseUrl/$cotisationId/paiement',
data: {
'montant': montant,
@@ -124,9 +321,27 @@ class ContributionRepository {
throw Exception('Erreur lors de l\'enregistrement du paiement: ${response.statusCode}');
}
/// Récupère les statistiques des cotisations
/// Synthèse personnelle du membre connecté (GET /api/cotisations/mes-cotisations/synthese)
Future<Map<String, dynamic>?> getMesCotisationsSynthese() async {
try {
final response = await _apiClient.get('$_baseUrl/mes-cotisations/synthese');
if (response.statusCode == 200 && response.data != null) {
final data = response.data is Map<String, dynamic>
? response.data as Map<String, dynamic>
: Map<String, dynamic>.from(response.data as Map);
data['isMesSynthese'] = true;
return data;
}
return null;
} catch (e, st) {
AppLogger.error('ContributionRepository: getMesCotisationsSynthese échoué', error: e, stackTrace: st);
rethrow;
}
}
/// Récupère les statistiques des cotisations (globales ou mes selon usage)
Future<Map<String, dynamic>> getStatistiques() async {
final response = await _dio.get('$_baseUrl/statistiques');
final response = await _apiClient.get('$_baseUrl/statistiques');
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
}
@@ -135,7 +350,7 @@ class ContributionRepository {
/// Envoie un rappel de paiement
Future<void> envoyerRappel(String cotisationId) async {
final response = await _dio.post('$_baseUrl/$cotisationId/rappel');
final response = await _apiClient.post('$_baseUrl/$cotisationId/rappel');
if (response.statusCode != 200) {
throw Exception('Erreur lors de l\'envoi du rappel');
}
@@ -143,7 +358,7 @@ class ContributionRepository {
/// Génère les cotisations annuelles
Future<int> genererCotisationsAnnuelles(int annee) async {
final response = await _dio.post(
final response = await _apiClient.post(
'$_baseUrl/generer',
data: {'annee': annee},
);
@@ -154,6 +369,23 @@ class ContributionRepository {
}
}
/// Résultat de l'initiation d'un paiement Wave (redirection vers l'app Wave).
class WavePaiementInitResult {
final String redirectUrl;
final String waveLaunchUrl;
final String? waveCheckoutSessionId;
final String? clientReference;
final String message;
const WavePaiementInitResult({
required this.redirectUrl,
required this.waveLaunchUrl,
this.waveCheckoutSessionId,
this.clientReference,
required this.message,
});
}
/// Résultat paginé de cotisations
class ContributionPageResult {
final List<ContributionModel> contributions;

View File

@@ -1,21 +0,0 @@
/// Configuration de l'injection de dépendances pour le module Cotisations
library cotisations_di;
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';
import '../bloc/contributions_bloc.dart';
import '../data/repositories/contribution_repository.dart';
/// Enregistrer les dépendances du module Cotisations
void registerCotisationsDependencies(GetIt getIt) {
// Repository
getIt.registerLazySingleton<ContributionRepository>(
() => ContributionRepository(getIt<Dio>()),
);
// BLoC
getIt.registerFactory<ContributionsBloc>(
() => ContributionsBloc(getIt<ContributionRepository>()),
);
}

View File

@@ -0,0 +1,66 @@
/// Interface du repository des contributions (Clean Architecture)
library contribution_repository_interface;
import '../../data/models/contribution_model.dart';
import '../../data/repositories/contribution_repository.dart' show ContributionPageResult, WavePaiementInitResult;
/// Interface définissant le contrat du repository des contributions
/// Implémentée par ContributionRepositoryImpl dans la couche data
abstract class IContributionRepository {
/// Récupère toutes les cotisations du membre connecté
Future<ContributionPageResult> getMesCotisations({int page = 0, int size = 50});
/// Récupère une cotisation par ID
Future<ContributionModel> getCotisationById(String id);
/// Crée une nouvelle cotisation
Future<ContributionModel> createCotisation(ContributionModel contribution);
/// Met à jour une cotisation existante
Future<ContributionModel> updateCotisation(String id, ContributionModel contribution);
/// Supprime une cotisation
Future<void> deleteCotisation(String id);
/// Enregistre un paiement pour une cotisation
Future<ContributionModel> enregistrerPaiement(
String cotisationId, {
required double montant,
required DateTime datePaiement,
required String methodePaiement,
String? numeroPaiement,
String? referencePaiement,
});
/// Initie un paiement en ligne (Wave)
Future<WavePaiementInitResult> initierPaiementEnLigne({
required String cotisationId,
required String methodePaiement,
required String numeroTelephone,
});
/// Récupère la synthèse des cotisations du membre
Future<Map<String, dynamic>?> getMesCotisationsSynthese();
/// Récupère les statistiques globales
Future<Map<String, dynamic>> getStatistiques();
/// Récupère les cotisations en attente
Future<ContributionPageResult> getMesCotisationsEnAttente();
/// Récupère les cotisations avec filtres (admin)
Future<ContributionPageResult> getCotisations({
int page = 0,
int size = 20,
String? membreId,
String? statut,
String? type,
int? annee,
});
/// Envoie un rappel de paiement
Future<void> envoyerRappel(String cotisationId);
/// Génère les cotisations annuelles pour tous les membres
Future<int> genererCotisationsAnnuelles(int annee);
}

View File

@@ -0,0 +1,24 @@
/// Use case: Créer une nouvelle contribution
library create_contribution;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour créer une nouvelle cotisation
@injectable
class CreateContribution {
final IContributionRepository _repository;
CreateContribution(this._repository);
/// Exécute le use case
///
/// [contribution] - Modèle de la cotisation à créer
///
/// Retourne la contribution créée avec son ID généré
/// Lève une exception en cas d'erreur de validation ou de création
Future<ContributionModel> call(ContributionModel contribution) async {
return _repository.createCotisation(contribution);
}
}

View File

@@ -0,0 +1,23 @@
/// Use case: Supprimer une contribution
library delete_contribution;
import 'package:injectable/injectable.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour supprimer une cotisation
@injectable
class DeleteContribution {
final IContributionRepository _repository;
DeleteContribution(this._repository);
/// Exécute le use case
///
/// [id] - UUID de la cotisation à supprimer
///
/// Supprime la contribution de manière définitive
/// Lève une exception si la contribution n'existe pas ou ne peut être supprimée
Future<void> call(String id) async {
return _repository.deleteCotisation(id);
}
}

View File

@@ -0,0 +1,24 @@
/// Use case: Récupérer une contribution par son ID
library get_contribution_by_id;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour récupérer le détail d'une contribution
@injectable
class GetContributionById {
final IContributionRepository _repository;
GetContributionById(this._repository);
/// Exécute le use case
///
/// [id] - UUID de la cotisation
///
/// Retourne le détail complet de la contribution
/// Lève une exception si la contribution n'existe pas
Future<ContributionModel> call(String id) async {
return _repository.getCotisationById(id);
}
}

View File

@@ -0,0 +1,33 @@
/// Use case: Récupérer l'historique des contributions d'un membre
library get_contribution_history;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../../data/repositories/contribution_repository.dart' show ContributionPageResult;
import '../repositories/contribution_repository.dart';
/// Use case pour récupérer l'historique des paiements de cotisations
@injectable
class GetContributionHistory {
final IContributionRepository _repository;
GetContributionHistory(this._repository);
/// Exécute le use case
///
/// [page] - Numéro de page (pagination)
/// [size] - Taille de la page
/// [annee] - Filtrer par année (optionnel)
/// [statut] - Filtrer par statut (optionnel)
///
/// Retourne l'historique paginé des cotisations du membre
/// Inclut toutes les cotisations (payées, en attente, en retard)
Future<ContributionPageResult> call({
int page = 0,
int size = 50,
int? annee,
ContributionStatus? statut,
}) async {
return _repository.getMesCotisations(page: page, size: size);
}
}

View File

@@ -0,0 +1,27 @@
/// Use case: Récupérer les statistiques personnelles des contributions
library get_contribution_stats;
import 'package:injectable/injectable.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour récupérer les statistiques de cotisations du membre
@injectable
class GetContributionStats {
final IContributionRepository _repository;
GetContributionStats(this._repository);
/// Exécute le use case
///
/// Retourne un Map contenant les statistiques personnelles:
/// - montantDu: Montant total dû pour l'année en cours
/// - totalPayeAnnee: Montant total payé pour l'année
/// - cotisationsEnAttente: Nombre de cotisations en attente
/// - prochaineEcheance: Date de la prochaine échéance
/// - tauxPaiement: Taux de paiement en pourcentage
///
/// Retourne null si aucune donnée n'est disponible
Future<Map<String, dynamic>?> call() async {
return _repository.getMesCotisationsSynthese();
}
}

View File

@@ -0,0 +1,22 @@
/// Use case: Récupérer toutes les contributions du membre connecté
library get_contributions;
import 'package:injectable/injectable.dart';
import '../../data/repositories/contribution_repository.dart' show ContributionPageResult;
import '../repositories/contribution_repository.dart';
/// Use case pour récupérer la liste des contributions du membre connecté
@injectable
class GetContributions {
final IContributionRepository _repository;
GetContributions(this._repository);
/// Exécute le use case
///
/// Retourne la liste paginée des cotisations du membre connecté
/// via l'endpoint GET /api/cotisations/mes-cotisations
Future<ContributionPageResult> call({int page = 0, int size = 50}) async {
return _repository.getMesCotisations(page: page, size: size);
}
}

View File

@@ -0,0 +1,43 @@
/// Use case: Enregistrer un paiement pour une contribution
library pay_contribution;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour enregistrer un paiement de cotisation
@injectable
class PayContribution {
final IContributionRepository _repository;
PayContribution(this._repository);
/// Exécute le use case
///
/// [cotisationId] - UUID de la cotisation à payer
/// [montant] - Montant du paiement
/// [datePaiement] - Date du paiement
/// [methodePaiement] - Méthode de paiement (WAVE, ESPECES, VIREMENT, etc.)
/// [numeroPaiement] - Numéro de transaction (optionnel)
/// [referencePaiement] - Référence du paiement (optionnel)
///
/// Retourne la contribution mise à jour avec le paiement enregistré
/// Lève une exception en cas d'erreur de validation ou d'enregistrement
Future<ContributionModel> call({
required String cotisationId,
required double montant,
required DateTime datePaiement,
required String methodePaiement,
String? numeroPaiement,
String? referencePaiement,
}) async {
return _repository.enregistrerPaiement(
cotisationId,
montant: montant,
datePaiement: datePaiement,
methodePaiement: methodePaiement,
numeroPaiement: numeroPaiement,
referencePaiement: referencePaiement,
);
}
}

View File

@@ -0,0 +1,25 @@
/// Use case: Mettre à jour une contribution existante
library update_contribution;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour modifier une cotisation
@injectable
class UpdateContribution {
final IContributionRepository _repository;
UpdateContribution(this._repository);
/// Exécute le use case
///
/// [id] - UUID de la cotisation à modifier
/// [contribution] - Données mises à jour
///
/// Retourne la contribution modifiée
/// Lève une exception si la contribution n'existe pas ou erreur de validation
Future<ContributionModel> call(String id, ContributionModel contribution) async {
return _repository.updateCotisation(id, contribution);
}
}

View File

@@ -1,20 +1,20 @@
/// Page de gestion des contributions
library contributions_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../shared/widgets/error_widget.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/design_system/tokens/app_typography.dart';
import '../../../../shared/widgets/info_badge.dart';
import '../../../../shared/widgets/loading_widget.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import '../../bloc/contributions_state.dart';
import '../../data/models/contribution_model.dart';
import '../../../../shared/widgets/error_widget.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_bloc.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_event.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_state.dart';
import 'package:unionflow_mobile_apps/features/contributions/data/models/contribution_model.dart';
import 'package:unionflow_mobile_apps/features/contributions/presentation/widgets/payment_dialog.dart';
import 'package:unionflow_mobile_apps/features/contributions/presentation/widgets/create_contribution_dialog.dart';
import '../widgets/payment_dialog.dart';
import '../../../members/bloc/membres_bloc.dart';
import 'package:unionflow_mobile_apps/features/contributions/presentation/pages/mes_statistiques_cotisations_page.dart';
/// Page principale des contributions
/// Page de gestion des contributions - Version Design System
class ContributionsPage extends StatefulWidget {
const ContributionsPage({super.key});
@@ -25,13 +25,12 @@ class ContributionsPage extends StatefulWidget {
class _ContributionsPageState extends State<ContributionsPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA');
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA', decimalDigits: 0);
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_loadContributions();
}
@override
@@ -60,60 +59,39 @@ class _ContributionsPageState extends State<ContributionsPage>
@override
Widget build(BuildContext context) {
return BlocListener<ContributionsBloc, ContributionsState>(
listener: (context, state) {
// Gestion des erreurs avec SnackBar
if (state is ContributionsError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
action: SnackBarAction(
label: 'Réessayer',
textColor: Colors.white,
onPressed: _loadContributions,
),
),
);
}
},
child: Scaffold(
appBar: AppBar(
title: const Text('Cotisations'),
bottom: TabBar(
controller: _tabController,
onTap: (_) => _loadContributions(),
tabs: const [
Tab(text: 'Toutes', icon: Icon(Icons.list)),
Tab(text: 'Payées', icon: Icon(Icons.check_circle)),
Tab(text: 'Non payées', icon: Icon(Icons.pending)),
Tab(text: 'En retard', icon: Icon(Icons.warning)),
],
),
return Scaffold(
backgroundColor: ColorTokens.background,
appBar: UFAppBar(
title: 'Cotisations',
actions: [
IconButton(
icon: const Icon(Icons.bar_chart),
icon: const Icon(Icons.bar_chart, size: 20),
onPressed: () => _showStats(),
tooltip: 'Statistiques',
),
IconButton(
icon: const Icon(Icons.add),
icon: const Icon(Icons.add_circle_outline, size: 20),
onPressed: () => _showCreateDialog(),
tooltip: 'Nouvelle contribution',
),
],
),
body: TabBarView(
bottom: TabBar(
controller: _tabController,
children: [
_buildContributionsList(),
_buildContributionsList(),
_buildContributionsList(),
_buildContributionsList(),
onTap: (_) => _loadContributions(),
labelColor: ColorTokens.onPrimary,
unselectedLabelColor: ColorTokens.onPrimary.withOpacity(0.7),
indicatorColor: ColorTokens.onPrimary,
labelStyle: AppTypography.badgeText.copyWith(fontWeight: FontWeight.bold),
tabs: const [
Tab(text: 'Toutes'),
Tab(text: 'Payées'),
Tab(text: 'Dues'),
Tab(text: 'Retard'),
],
),
),
body: TabBarView(
controller: _tabController,
children: List.generate(4, (_) => _buildContributionsList()),
),
);
}
@@ -134,379 +112,274 @@ class _ContributionsPageState extends State<ContributionsPage>
}
if (state is ContributionsLoaded) {
if (state.contributions.isEmpty) {
return const Center(
child: EmptyDataWidget(
message: 'Aucune contribution trouvée',
icon: Icons.payment,
),
);
}
return RefreshIndicator(
onRefresh: () async => _loadContributions(),
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.contributions.length,
itemBuilder: (context, index) {
final contribution = state.contributions[index];
return _buildContributionCard(contribution);
},
),
);
return _buildListOrEmpty(state.contributions);
}
return const Center(child: Text('Chargez les cotisations'));
// Au retour de "Mes Statistiques", la liste peut être conservée dans ContributionsStatsLoaded
if (state is ContributionsStatsLoaded) {
if (state.contributions != null) {
return _buildListOrEmpty(state.contributions!);
}
// Stats ouverts sans liste préalable : charger les contributions une fois
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.read<ContributionsBloc>().add(const LoadContributions());
}
});
return const Center(child: Text('Initialisation...'));
}
return const Center(child: Text('Initialisation...'));
},
);
}
Widget _buildContributionCard(ContributionModel contribution) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () => _showContributionDetails(contribution),
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(
contribution.membreNomComplet,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
contribution.libellePeriode,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
_buildStatutChip(contribution.statut),
],
),
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Montant',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
_currencyFormat.format(contribution.montant),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
if (contribution.montantPaye != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Payé',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
_currencyFormat.format(contribution.montantPaye),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Échéance',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
DateFormat('dd/MM/yyyy').format(contribution.dateEcheance),
style: TextStyle(
fontSize: 14,
color: contribution.estEnRetard ? Colors.red : null,
),
),
],
),
],
),
if (contribution.statut == ContributionStatus.partielle)
Padding(
padding: const EdgeInsets.only(top: 12),
child: LinearProgressIndicator(
value: contribution.pourcentagePaye / 100,
backgroundColor: Colors.grey[200],
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
),
),
],
Widget _buildListOrEmpty(List<ContributionModel> contributions) {
if (contributions.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.payment_outlined, size: 48, color: ColorTokens.onSurfaceVariant.withOpacity(0.5)),
const SizedBox(height: SpacingTokens.md),
Text('Aucune contribution', style: AppTypography.bodyTextSmall),
],
),
);
}
return Column(
children: [
_buildMiniStats(contributions),
Expanded(
child: RefreshIndicator(
onRefresh: () async => _loadContributions(),
child: ListView.builder(
padding: const EdgeInsets.all(SpacingTokens.md),
itemCount: contributions.length,
itemBuilder: (context, index) => _buildContributionCard(contributions[index]),
),
),
),
],
);
}
Widget _buildMiniStats(List<ContributionModel> contributions) {
final totalDue = contributions.fold(0.0, (sum, c) => sum + c.montant);
final totalPaid = contributions.fold(0.0, (sum, c) => sum + (c.montantPaye ?? 0.0));
return Container(
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.md, vertical: SpacingTokens.sm),
color: ColorTokens.surfaceVariant.withOpacity(0.3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildMetric('DU', _currencyFormat.format(totalDue), ColorTokens.secondary),
_buildMetric('PAYÉ', _currencyFormat.format(totalPaid), ColorTokens.success),
_buildMetric('RESTANT', _currencyFormat.format(totalDue - totalPaid), ColorTokens.error),
],
),
);
}
Widget _buildStatutChip(ContributionStatus statut) {
Color color;
String label;
IconData icon;
Widget _buildMetric(String label, String value, Color color) {
return Column(
children: [
Text(label, style: AppTypography.badgeText.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(value, style: AppTypography.headerSmall.copyWith(color: color, fontWeight: FontWeight.bold)),
],
);
}
Widget _buildContributionCard(ContributionModel contribution) {
return UFCard(
margin: const EdgeInsets.only(bottom: SpacingTokens.sm),
onTap: () => _showContributionDetails(contribution),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(contribution.membreNomComplet, style: AppTypography.headerSmall),
Text(contribution.libellePeriode, style: AppTypography.subtitleSmall),
],
),
),
_buildStatutBadge(contribution.statut, contribution.estEnRetard),
],
),
const SizedBox(height: SpacingTokens.md),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildAmountValue('Montant', contribution.montant),
if (contribution.montantPaye != null && contribution.montantPaye! > 0)
_buildAmountValue('Payé', contribution.montantPaye!, color: ColorTokens.success),
_buildAmountValue('Échéance', contribution.dateEcheance, isDate: true),
],
),
if (contribution.statut == ContributionStatus.partielle) ...[
const SizedBox(height: SpacingTokens.sm),
ClipRRect(
borderRadius: BorderRadius.circular(RadiusTokens.sm),
child: LinearProgressIndicator(
value: contribution.pourcentagePaye / 100,
backgroundColor: ColorTokens.surfaceVariant,
valueColor: const AlwaysStoppedAnimation<Color>(ColorTokens.primary),
minHeight: 4,
),
),
],
],
),
);
}
Widget _buildAmountValue(String label, dynamic value, {Color? color, bool isDate = false}) {
String displayValue = isDate
? DateFormat('dd/MM/yy').format(value as DateTime)
: _currencyFormat.format(value as double);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppTypography.badgeText.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(displayValue, style: AppTypography.bodyTextSmall.copyWith(
color: color ?? ColorTokens.onSurface,
fontWeight: FontWeight.w600,
)),
],
);
}
Widget _buildStatutBadge(ContributionStatus statut, bool enRetard) {
if (enRetard && statut != ContributionStatus.payee) {
return const InfoBadge(text: 'RETARD', backgroundColor: Color(0xFFFFEBEB), textColor: ColorTokens.error);
}
switch (statut) {
case ContributionStatus.payee:
color = Colors.green;
label = 'Payée';
icon = Icons.check_circle;
break;
return const InfoBadge(text: 'PAYÉE', backgroundColor: Color(0xFFE3F9E5), textColor: ColorTokens.success);
case ContributionStatus.nonPayee:
color = Colors.orange;
label = 'Non payée';
icon = Icons.pending;
break;
case ContributionStatus.enRetard:
color = Colors.red;
label = 'En retard';
icon = Icons.warning;
break;
case ContributionStatus.enAttente:
return const InfoBadge(text: 'DUE', backgroundColor: Color(0xFFFFF4E5), textColor: ColorTokens.warning);
case ContributionStatus.partielle:
color = Colors.blue;
label = 'Partielle';
icon = Icons.hourglass_bottom;
break;
return const InfoBadge(text: 'PARTIELLE', backgroundColor: Color(0xFFE5F1FF), textColor: ColorTokens.info);
case ContributionStatus.annulee:
color = Colors.grey;
label = 'Annulée';
icon = Icons.cancel;
break;
return InfoBadge.neutral('ANNULÉE');
default:
return InfoBadge.neutral(statut.name.toUpperCase());
}
return Chip(
avatar: Icon(icon, size: 16, color: Colors.white),
label: Text(label),
backgroundColor: color,
labelStyle: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
);
}
void _showContributionDetails(ContributionModel contribution) {
showDialog(
showModalBottomSheet(
context: context,
builder: (context) => AlertDialog(
title: Text(contribution.membreNomComplet),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDetailRow('Période', contribution.libellePeriode),
_buildDetailRow('Montant', _currencyFormat.format(contribution.montant)),
if (contribution.montantPaye != null)
_buildDetailRow('Payé', _currencyFormat.format(contribution.montantPaye)),
_buildDetailRow('Restant', _currencyFormat.format(contribution.montantRestant)),
_buildDetailRow(
'Échéance',
DateFormat('dd/MM/yyyy').format(contribution.dateEcheance),
),
if (contribution.datePaiement != null)
_buildDetailRow(
'Date paiement',
DateFormat('dd/MM/yyyy').format(contribution.datePaiement!),
),
if (contribution.methodePaiement != null)
_buildDetailRow('Méthode', _getMethodePaiementLabel(contribution.methodePaiement!)),
backgroundColor: ColorTokens.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(RadiusTokens.lg))),
builder: (context) => Padding(
padding: const EdgeInsets.all(SpacingTokens.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(contribution.membreNomComplet, style: AppTypography.headerSmall),
Text(contribution.libellePeriode, style: AppTypography.subtitleSmall),
const Divider(height: SpacingTokens.xl),
_buildDetailRow('Montant Total', _currencyFormat.format(contribution.montant)),
_buildDetailRow('Montant Payé', _currencyFormat.format(contribution.montantPaye ?? 0.0)),
_buildDetailRow('Reste à payer', _currencyFormat.format(contribution.montantRestant), isCritical: contribution.montantRestant > 0),
_buildDetailRow('Date d\'échéance', DateFormat('dd MMMM yyyy').format(contribution.dateEcheance)),
if (contribution.description != null) ...[
const SizedBox(height: SpacingTokens.md),
Text(contribution.description!, style: AppTypography.bodyTextSmall),
],
),
),
actions: [
if (contribution.statut != ContributionStatus.payee)
TextButton.icon(
onPressed: () {
Navigator.pop(context);
_showPaymentDialog(contribution);
},
icon: const Icon(Icons.payment),
label: const Text('Enregistrer paiement'),
const SizedBox(height: SpacingTokens.xl),
Row(
children: [
if (contribution.statut != ContributionStatus.payee)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: UFPrimaryButton(
label: 'Enregistrer Paiement',
onPressed: () {
Navigator.pop(context);
_showPaymentDialog(contribution);
},
),
),
),
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: ColorTokens.outline),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(RadiusTokens.md)),
),
child: Text('Fermer', style: AppTypography.actionText.copyWith(color: ColorTokens.onSurface)),
),
),
],
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
],
),
),
);
}
Widget _buildDetailRow(String label, String value) {
Widget _buildDetailRow(String label, String value, {bool isCritical = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.xs),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
),
Text(
value,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Text(label, style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(value, style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.bold,
color: isCritical ? ColorTokens.error : ColorTokens.onSurface,
)),
],
),
);
}
String _getMethodePaiementLabel(PaymentMethod methode) {
switch (methode) {
case PaymentMethod.especes:
return 'Espèces';
case PaymentMethod.cheque:
return 'Chèque';
case PaymentMethod.virement:
return 'Virement';
case PaymentMethod.carteBancaire:
return 'Carte bancaire';
case PaymentMethod.waveMoney:
return 'Wave Money';
case PaymentMethod.orangeMoney:
return 'Orange Money';
case PaymentMethod.freeMoney:
return 'Free Money';
case PaymentMethod.mobileMoney:
return 'Mobile Money';
case PaymentMethod.autre:
return 'Autre';
}
}
void _showPaymentDialog(ContributionModel contribution) {
final contributionsBloc = context.read<ContributionsBloc>();
showDialog(
context: context,
builder: (context) => BlocProvider.value(
value: context.read<ContributionsBloc>(),
value: contributionsBloc,
child: PaymentDialog(cotisation: contribution),
),
);
}
void _showCreateDialog() {
final contributionsBloc = context.read<ContributionsBloc>();
showDialog(
context: context,
builder: (context) => MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<ContributionsBloc>()),
BlocProvider.value(value: context.read<MembresBloc>()),
],
builder: (context) => BlocProvider.value(
value: contributionsBloc,
child: const CreateContributionDialog(),
),
);
}
void _showStats() {
context.read<ContributionsBloc>().add(const LoadContributionsStats());
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Statistiques'),
content: BlocBuilder<ContributionsBloc, ContributionsState>(
builder: (context, state) {
if (state is ContributionsStatsLoaded) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatRow('Total', state.stats['total'].toString()),
_buildStatRow('Payées', state.stats['payees'].toString()),
_buildStatRow('Non payées', state.stats['nonPayees'].toString()),
_buildStatRow('En retard', state.stats['enRetard'].toString()),
const Divider(),
_buildStatRow(
'Montant total',
_currencyFormat.format(state.stats['montantTotal']),
),
_buildStatRow(
'Montant payé',
_currencyFormat.format(state.stats['montantPaye']),
),
_buildStatRow(
'Taux recouvrement',
'${state.stats['tauxRecouvrement'].toStringAsFixed(1)}%',
),
],
);
}
return const AppLoadingWidget();
},
final contributionsBloc = context.read<ContributionsBloc>();
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => BlocProvider.value(
value: contributionsBloc,
child: const MesStatistiquesCotisationsPage(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
Widget _buildStatRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
);
}
}

View File

@@ -4,27 +4,42 @@ library cotisations_page_wrapper;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import 'contributions_page.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_bloc.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_event.dart';
import 'package:unionflow_mobile_apps/features/contributions/presentation/pages/contributions_page.dart';
import 'package:unionflow_mobile_apps/features/members/bloc/membres_bloc.dart';
final _getIt = GetIt.instance;
/// Wrapper qui fournit le BLoC à la page des cotisations
/// Wrapper qui fournit les BLoCs à la page des cotisations (et au dialogue de création)
class CotisationsPageWrapper extends StatelessWidget {
const CotisationsPageWrapper({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<ContributionsBloc>(
create: (context) {
final bloc = _getIt<ContributionsBloc>();
// Charger les cotisations au démarrage
bloc.add(const LoadContributions());
return bloc;
},
return MultiBlocProvider(
providers: [
BlocProvider<ContributionsBloc>(
create: (context) {
final bloc = _getIt<ContributionsBloc>();
bloc.add(const LoadContributions());
return bloc;
},
),
BlocProvider<MembresBloc>(
create: (context) => _getIt<MembresBloc>(),
),
],
child: const ContributionsPage(),
);
}
}
/// Alias pour la route /finances et références anglaises
class ContributionsPageWrapper extends StatelessWidget {
const ContributionsPageWrapper({super.key});
@override
Widget build(BuildContext context) => const CotisationsPageWrapper();
}

View File

@@ -0,0 +1,564 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/utils/logger.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import '../../../../shared/widgets/loading_widget.dart';
import '../../../../shared/widgets/error_widget.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import '../../bloc/contributions_state.dart';
import '../../data/models/contribution_model.dart';
/// Page dédiée « Mes statistiques cotisations » : KPIs, graphiques et synthèse.
/// Données réelles via GET /api/cotisations/mes-cotisations/synthese + liste des cotisations.
class MesStatistiquesCotisationsPage extends StatefulWidget {
const MesStatistiquesCotisationsPage({super.key});
@override
State<MesStatistiquesCotisationsPage> createState() => _MesStatistiquesCotisationsPageState();
}
class _MesStatistiquesCotisationsPageState extends State<MesStatistiquesCotisationsPage> {
Map<String, dynamic>? _synthese;
List<ContributionModel>? _cotisations;
String? _error;
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA', decimalDigits: 0);
@override
void initState() {
super.initState();
// Charge uniquement la synthèse ; la liste est conservée dans l'état pour ne pas perdre l'onglet Toutes au retour.
context.read<ContributionsBloc>().add(const LoadContributionsStats());
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.background,
appBar: UFAppBar(
title: 'Mes statistiques cotisations',
backgroundColor: ColorTokens.surface,
foregroundColor: ColorTokens.onSurface,
),
body: BlocListener<ContributionsBloc, ContributionsState>(
listener: (context, state) {
if (state is ContributionsStatsLoaded) {
setState(() {
_synthese = state.stats;
_cotisations = state.contributions;
_error = null;
});
}
if (state is ContributionsLoaded) {
setState(() {
_cotisations = state.contributions;
_error = null;
});
}
if (state is ContributionsError) {
setState(() => _error = state.message);
}
},
child: RefreshIndicator(
onRefresh: () async {
context.read<ContributionsBloc>().add(const LoadContributionsStats());
},
child: _buildBody(),
),
),
);
}
Widget _buildBody() {
if (_error != null) {
return Center(
child: AppErrorWidget(
message: _error!,
onRetry: () {
context.read<ContributionsBloc>().add(const LoadContributionsStats());
context.read<ContributionsBloc>().add(const LoadContributions());
},
),
);
}
if (_synthese == null && _cotisations == null) {
return const Center(child: AppLoadingWidget());
}
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
const SizedBox(height: 24),
_buildKpiCards(),
const SizedBox(height: 20),
_buildTauxSection(),
const SizedBox(height: 20),
if (_cotisations != null && _cotisations!.isNotEmpty) _buildRepartitionChart(),
if (_cotisations != null && _cotisations!.isNotEmpty) const SizedBox(height: 20),
if (_cotisations != null && _cotisations!.isNotEmpty) _buildEvolutionSection(),
const SizedBox(height: 20),
_buildProchainesEcheances(),
const SizedBox(height: 32),
],
),
);
}
Widget _buildHeader() {
final annee = _synthese?['anneeEnCours'] is int
? _synthese!['anneeEnCours'] as int
: DateTime.now().year;
return Column(
children: [
Text(
'Synthèse $annee',
style: AppTypography.headerSmall.copyWith(fontSize: 20),
),
const SizedBox(height: 4),
Text(
'Votre situation cotisations',
style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant),
),
],
);
}
Widget _buildKpiCards() {
final montantDu = _toDouble(_synthese?['montantDu']);
final totalPayeAnnee = _toDouble(_synthese?['totalPayeAnnee']);
final enAttente = _synthese?['cotisationsEnAttente'] is int
? _synthese!['cotisationsEnAttente'] as int
: ((_synthese?['cotisationsEnAttente'] as num?)?.toInt() ?? 0);
final prochaineStr = _synthese?['prochaineEcheance']?.toString();
return Column(
children: [
Row(
children: [
Expanded(
child: _kpiCard(
'Montant dû',
_currencyFormat.format(montantDu),
icon: Icons.pending_actions_outlined,
color: montantDu > 0 ? UnionFlowColors.terracotta : UnionFlowColors.success,
),
),
const SizedBox(width: 12),
Expanded(
child: _kpiCard(
'Payé cette année',
_currencyFormat.format(totalPayeAnnee),
icon: Icons.check_circle_outline,
color: UnionFlowColors.unionGreen,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _kpiCard(
'En attente',
'$enAttente',
icon: Icons.schedule,
color: enAttente > 0 ? UnionFlowColors.gold : UnionFlowColors.success,
),
),
const SizedBox(width: 12),
Expanded(
child: _kpiCard(
'Prochaine échéance',
prochaineStr != null && prochaineStr.isNotEmpty && prochaineStr != 'null'
? _formatDate(prochaineStr)
: '',
icon: Icons.event,
color: UnionFlowColors.indigo,
),
),
],
),
],
);
}
Widget _kpiCard(String label, String value, {required IconData icon, required Color color}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Text(
value,
style: AppTypography.headerSmall.copyWith(color: color, fontSize: 15),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildTauxSection() {
final montantDu = _toDouble(_synthese?['montantDu']);
final totalPayeAnnee = _toDouble(_synthese?['totalPayeAnnee']);
final total = montantDu + totalPayeAnnee;
final taux = total > 0 ? (totalPayeAnnee / total * 100) : 0.0;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Taux de paiement',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: (taux / 100).clamp(0.0, 1.0),
minHeight: 12,
backgroundColor: ColorTokens.onSurfaceVariant.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(
taux >= 75 ? UnionFlowColors.success : (taux >= 50 ? UnionFlowColors.gold : UnionFlowColors.terracotta),
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('0 %', style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(
'${taux.toStringAsFixed(0)} %',
style: AppTypography.headerSmall.copyWith(color: UnionFlowColors.unionGreen, fontWeight: FontWeight.w700),
),
Text('100 %', style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
],
),
],
),
);
}
Widget _buildRepartitionChart() {
final paye = _cotisations!
.where((c) => c.statut == ContributionStatus.payee)
.fold<double>(0, (s, c) => s + (c.montantPaye ?? c.montant));
final du = _cotisations!
.where((c) => c.statut != ContributionStatus.payee && c.statut != ContributionStatus.annulee)
.fold<double>(0, (s, c) => s + c.montant);
if (paye + du <= 0) return const SizedBox.shrink();
final sections = <PieChartSectionData>[];
if (paye > 0) {
sections.add(PieChartSectionData(
color: UnionFlowColors.unionGreen,
value: paye,
title: 'Payé',
radius: 60,
titleStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white),
));
}
if (du > 0) {
sections.add(PieChartSectionData(
color: UnionFlowColors.terracotta,
value: du,
title: '',
radius: 60,
titleStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white),
));
}
if (sections.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Répartition Payé / Dû',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: sections,
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_legendItem(UnionFlowColors.unionGreen, 'Payé', _currencyFormat.format(paye)),
_legendItem(UnionFlowColors.terracotta, '', _currencyFormat.format(du)),
],
),
],
),
);
}
Widget _legendItem(Color color, String label, String value) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(value, style: AppTypography.bodyTextSmall.copyWith(fontWeight: FontWeight.w600)),
],
),
],
);
}
Widget _buildEvolutionSection() {
final payees = _cotisations!.where((c) => c.statut == ContributionStatus.payee).toList();
if (payees.isEmpty) return const SizedBox.shrink();
final byMonth = <int, double>{};
for (final c in payees) {
final d = c.datePaiement ?? c.dateEcheance;
final month = d.month + d.year * 12;
byMonth[month] = (byMonth[month] ?? 0) + (c.montantPaye ?? c.montant);
}
final entries = byMonth.entries.toList()..sort((a, b) => a.key.compareTo(b.key));
if (entries.isEmpty) return const SizedBox.shrink();
final dataMaxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b);
final yMax = (dataMaxY * 1.1 + 1).clamp(1.0, double.infinity);
final yInterval = yMax / 4;
final spots = entries.asMap().entries.map((e) => FlSpot(e.key.toDouble(), e.value.value)).toList();
final n = spots.length;
final xInterval = n <= 5 ? 1.0 : (n - 1) / 4;
final xIntervalSafe = xInterval < 1 ? 1.0 : xInterval;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Paiements par période',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: LineChart(
LineChartData(
gridData: FlGridData(show: true, drawVerticalLine: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 44,
interval: yInterval,
getTitlesWidget: (v, _) => Text(_formatAxisAmount(v), style: const TextStyle(fontSize: 10)),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: xIntervalSafe,
getTitlesWidget: (v, _) {
final i = v.round();
if (i >= 0 && i < entries.length) {
final k = entries[i].key;
final m = k % 12 == 0 ? 12 : k % 12;
final y = k % 12 == 0 ? (k ~/ 12) - 1 : (k ~/ 12);
return Text(_formatAxisPeriod(m, y), style: const TextStyle(fontSize: 10));
}
return const SizedBox.shrink();
},
),
),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: true, border: Border(bottom: BorderSide(color: ColorTokens.outline), left: BorderSide(color: ColorTokens.outline))),
minX: 0,
maxX: (spots.length - 1).toDouble(),
minY: 0,
maxY: yMax,
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: UnionFlowColors.unionGreen,
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(show: true, color: UnionFlowColors.unionGreen.withOpacity(0.15)),
),
],
),
),
),
],
),
);
}
Widget _buildProchainesEcheances() {
final list = _cotisations ?? [];
final aRegler = list.where((c) => c.statut != ContributionStatus.payee && c.statut != ContributionStatus.annulee).toList();
aRegler.sort((a, b) => a.dateEcheance.compareTo(b.dateEcheance));
final top = aRegler.take(5).toList();
if (top.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Prochaines échéances à régler',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 12),
...top.map((c) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDate(c.dateEcheance.toIso8601String()),
style: AppTypography.bodyTextSmall,
),
Text(
_currencyFormat.format(c.montant),
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w600,
color: UnionFlowColors.terracotta,
),
),
],
),
)),
],
),
);
}
double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
String _formatDate(String isoOrRaw) {
try {
final dt = DateTime.tryParse(isoOrRaw);
if (dt != null) {
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc'];
return '${dt.day} ${months[dt.month - 1]} ${dt.year}';
}
} catch (e, st) {
AppLogger.warning('MesStatistiquesCotisations: format date invalide', tag: isoOrRaw);
}
return isoOrRaw;
}
String _formatShortAmount(double v) {
if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)}k';
return v.toStringAsFixed(0);
}
/// Format court pour laxe Y : 0, 25 k, 50 k, 1 M — peu de libellés, lisibles.
String _formatAxisAmount(double v) {
if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)} M';
if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)} k';
if (v < 1) return '0';
return v.toStringAsFixed(0);
}
String _monthShort(int m) {
const t = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc'];
return m >= 1 && m <= 12 ? t[m - 1] : '';
}
/// Libellé court pour laxe X : "Jan 25", "Avr 25" — peu de caractères.
String _formatAxisPeriod(int month, int year) {
final shortYear = year % 100;
return '${_monthShort(month)} $shortYear';
}
}

View File

@@ -3,13 +3,14 @@ library create_contribution_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../../../core/utils/logger.dart';
import 'package:intl/intl.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import '../../data/models/contribution_model.dart';
import '../../../members/bloc/membres_bloc.dart';
import '../../../members/bloc/membres_event.dart';
import '../../../members/bloc/membres_state.dart';
import '../../../members/data/models/membre_complete_model.dart';
import '../../../profile/domain/repositories/profile_repository.dart';
class CreateContributionDialog extends StatefulWidget {
@@ -25,15 +26,37 @@ class _CreateContributionDialogState extends State<CreateContributionDialog> {
final _descriptionController = TextEditingController();
ContributionType _selectedType = ContributionType.mensuelle;
dynamic _selectedMembre;
MembreCompletModel? _me;
DateTime _dateEcheance = DateTime.now().add(const Duration(days: 30));
bool _isLoading = false;
bool _isInitLoading = true;
@override
void initState() {
super.initState();
// Charger la liste des membres
context.read<MembresBloc>().add(const LoadMembres());
_loadMe();
}
Future<void> _loadMe() async {
try {
final user = await GetIt.instance<IProfileRepository>().getMe();
if (mounted) {
setState(() {
_me = user;
_isInitLoading = false;
});
}
} catch (e, st) {
AppLogger.error('CreateContributionDialog: chargement profil échoué', error: e, stackTrace: st);
if (mounted) {
setState(() {
_isInitLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de charger le profil. Réessayez.')),
);
}
}
}
@override
@@ -55,38 +78,21 @@ class _CreateContributionDialogState extends State<CreateContributionDialog> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Sélection du membre
BlocBuilder<MembresBloc, MembresState>(
builder: (context, state) {
if (state is MembresLoaded) {
return DropdownButtonFormField<dynamic>(
value: _selectedMembre,
decoration: const InputDecoration(
labelText: 'Membre',
border: OutlineInputBorder(),
),
items: state.membres.map((membre) {
return DropdownMenuItem(
value: membre,
child: Text('${membre.nom} ${membre.prenom}'),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedMembre = value;
});
},
validator: (value) {
if (value == null) {
return 'Veuillez sélectionner un membre';
}
return null;
},
);
}
return const CircularProgressIndicator();
},
),
// Utilisateur connecté
if (_isInitLoading)
const CircularProgressIndicator()
else if (_me != null)
TextFormField(
initialValue: '${_me!.prenom} ${_me!.nom}',
decoration: const InputDecoration(
labelText: 'Membre',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
enabled: false, // Lecture seule
)
else
const Text('Impossible de récupérer votre profil', style: TextStyle(color: Colors.red)),
const SizedBox(height: 16),
// Type de contribution
@@ -210,15 +216,15 @@ class _CreateContributionDialogState extends State<CreateContributionDialog> {
}
}
void _createContribution() {
Future<void> _createContribution() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedMembre == null) {
if (_me == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez sélectionner un membre'),
content: Text('Profil non chargé'),
backgroundColor: Colors.red,
),
);
@@ -229,10 +235,31 @@ class _CreateContributionDialogState extends State<CreateContributionDialog> {
_isLoading = true;
});
final membre = _me!;
String? organisationId = membre.organisationId?.trim().isNotEmpty == true
? membre.organisationId
: null;
String? organisationNom = membre.organisationNom;
if (organisationId == null || organisationId.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Aucune organisation disponible. Le membre et l\'utilisateur connecté doivent être rattachés à une organisation.'),
backgroundColor: Colors.red,
),
);
setState(() => _isLoading = false);
return;
}
final contribution = ContributionModel(
membreId: _selectedMembre!.id!,
membreNom: _selectedMembre!.nom,
membrePrenom: _selectedMembre!.prenom,
membreId: membre.id!,
membreNom: membre.nom,
membrePrenom: membre.prenom,
organisationId: organisationId,
organisationNom: organisationNom,
type: _selectedType,
annee: DateTime.now().year,
montant: double.parse(_montantController.text),

View File

@@ -1,13 +1,18 @@
/// Dialogue de paiement de contribution
/// Formulaire pour enregistrer un paiement de contribution
/// Formulaire pour enregistrer un paiement de contribution.
/// Pour Wave : appelle l'API Checkout, ouvre wave_launch_url (app Wave), retour automatique via deep link.
library payment_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:unionflow_mobile_apps/core/di/injection.dart';
import 'package:unionflow_mobile_apps/shared/constants/payment_method_assets.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import '../../data/models/contribution_model.dart';
import '../../domain/repositories/contribution_repository.dart';
/// Dialogue de paiement de contribution
class PaymentDialog extends StatefulWidget {
@@ -27,22 +32,24 @@ class _PaymentDialogState extends State<PaymentDialog> {
final _montantController = TextEditingController();
final _referenceController = TextEditingController();
final _notesController = TextEditingController();
final _wavePhoneController = TextEditingController();
PaymentMethod _selectedMethode = PaymentMethod.waveMoney;
DateTime _datePaiement = DateTime.now();
bool _waveLoading = false;
@override
void initState() {
super.initState();
// Pré-remplir avec le montant restant
_montantController.text = widget.cotisation.montantRestant.toStringAsFixed(0);
}
@override
void dispose() {
_montantController.dispose();
_referenceController.dispose();
_notesController.dispose();
_wavePhoneController.dispose();
super.dispose();
}
@@ -199,11 +206,15 @@ class _PaymentDialogState extends State<PaymentDialog> {
prefixIcon: Icon(Icons.payment),
),
items: PaymentMethod.values.map((methode) {
return DropdownMenuItem(
return DropdownMenuItem<PaymentMethod>(
value: methode,
child: Row(
children: [
Icon(_getMethodeIcon(methode), size: 20),
PaymentMethodIcon(
paymentMethodCode: methode.code,
width: 24,
height: 24,
),
const SizedBox(width: 8),
Text(_getMethodeLabel(methode)),
],
@@ -216,8 +227,28 @@ class _PaymentDialogState extends State<PaymentDialog> {
});
},
),
if (_selectedMethode == PaymentMethod.waveMoney) ...[
const SizedBox(height: 12),
TextFormField(
controller: _wavePhoneController,
decoration: const InputDecoration(
labelText: 'Numéro Wave (9 chiffres) *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone_android),
hintText: 'Ex: 771234567',
),
keyboardType: TextInputType.number,
validator: (value) {
if (_selectedMethode != PaymentMethod.waveMoney) return null;
final digits = value?.replaceAll(RegExp(r'\D'), '') ?? '';
if (digits.length < 9) {
return 'Numéro Wave requis (9 chiffres) pour payer via Wave';
}
return null;
},
),
],
const SizedBox(height: 12),
// Date de paiement
InkWell(
onTap: () => _selectDate(context),
@@ -278,12 +309,20 @@ class _PaymentDialogState extends State<PaymentDialog> {
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _submitForm,
onPressed: _waveLoading ? null : _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white,
),
child: const Text('Enregistrer le paiement'),
child: _waveLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Text(_selectedMethode == PaymentMethod.waveMoney
? 'Ouvrir Wave pour payer'
: 'Enregistrer le paiement'),
),
],
),
@@ -354,42 +393,80 @@ class _PaymentDialogState extends State<PaymentDialog> {
}
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
final montant = double.parse(_montantController.text);
// Créer la cotisation mise à jour
widget.cotisation.copyWith(
montantPaye: (widget.cotisation.montantPaye ?? 0) + montant,
datePaiement: _datePaiement,
methodePaiement: _selectedMethode,
referencePaiement: _referenceController.text.isNotEmpty ? _referenceController.text : null,
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
statut: (widget.cotisation.montantPaye ?? 0) + montant >= widget.cotisation.montant
? ContributionStatus.payee
: ContributionStatus.partielle,
);
Future<void> _submitForm() async {
if (!_formKey.currentState!.validate()) return;
// Envoyer l'événement au BLoC
context.read<ContributionsBloc>().add(RecordPayment(
contributionId: widget.cotisation.id!,
montant: montant,
methodePaiement: _selectedMethode,
datePaiement: _datePaiement,
reference: _referenceController.text.isNotEmpty ? _referenceController.text : null,
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
));
// Fermer le dialogue
Navigator.pop(context);
// Afficher un message de succès
if (_selectedMethode == PaymentMethod.waveMoney) {
await _submitWavePayment();
return;
}
final montant = double.parse(_montantController.text);
// LUI est rafraîchie par le BLoC après RecordPayment ; pas besoin de copyWith local.
context.read<ContributionsBloc>().add(RecordPayment(
contributionId: widget.cotisation.id!,
montant: montant,
methodePaiement: _selectedMethode,
datePaiement: _datePaiement,
reference: _referenceController.text.isNotEmpty ? _referenceController.text : null,
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Paiement enregistré avec succès'),
backgroundColor: Colors.green,
),
);
}
/// Initie le paiement Wave : appel API Checkout, ouverture de l'app Wave, retour via deep link.
Future<void> _submitWavePayment() async {
if (widget.cotisation.id == null || widget.cotisation.id!.isEmpty) return;
final phone = _wavePhoneController.text.replaceAll(RegExp(r'\D'), '');
if (phone.length < 9) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Paiement enregistré avec succès'),
const SnackBar(content: Text('Indiquez votre numéro Wave (9 chiffres)'), backgroundColor: Colors.orange),
);
return;
}
setState(() => _waveLoading = true);
try {
final repo = getIt<IContributionRepository>();
final result = await repo.initierPaiementEnLigne(
cotisationId: widget.cotisation.id!,
methodePaiement: 'WAVE',
numeroTelephone: phone,
);
final url = result.waveLaunchUrl.isNotEmpty ? result.waveLaunchUrl : result.redirectUrl;
if (url.isEmpty) {
throw Exception('URL Wave non reçue');
}
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
await launchUrl(uri);
}
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.message),
backgroundColor: Colors.green,
),
);
context.read<ContributionsBloc>().add(const LoadContributions());
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Wave: ${e.toString().replaceFirst('Exception: ', '')}'),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) setState(() => _waveLoading = false);
}
}
}