Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
367
lib/features/contributions/bloc/contributions_bloc.dart
Normal file
367
lib/features/contributions/bloc/contributions_bloc.dart
Normal file
@@ -0,0 +1,367 @@
|
||||
/// BLoC pour la gestion des contributions
|
||||
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' 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 les use cases (Clean Architecture)
|
||||
@injectable
|
||||
class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
|
||||
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._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);
|
||||
on<UpdateContribution>(_onUpdateContribution);
|
||||
on<DeleteContribution>(_onDeleteContribution);
|
||||
on<SearchContributions>(_onSearchContributions);
|
||||
on<LoadContributionsByMembre>(_onLoadContributionsByMembre);
|
||||
on<LoadContributionsPayees>(_onLoadContributionsPayees);
|
||||
on<LoadContributionsNonPayees>(_onLoadContributionsNonPayees);
|
||||
on<LoadContributionsEnRetard>(_onLoadContributionsEnRetard);
|
||||
on<RecordPayment>(_onRecordPayment);
|
||||
on<LoadContributionsStats>(_onLoadContributionsStats);
|
||||
on<GenerateAnnualContributions>(_onGenerateAnnualContributions);
|
||||
on<SendPaymentReminder>(_onSendPaymentReminder);
|
||||
}
|
||||
|
||||
Future<void> _onLoadContributions(
|
||||
LoadContributions event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('ContributionsBloc', 'LoadContributions', data: {
|
||||
'page': event.page,
|
||||
'size': event.size,
|
||||
});
|
||||
|
||||
emit(const ContributionsLoading(message: 'Chargement des contributions...'));
|
||||
|
||||
// Use case: Get contributions
|
||||
final result = await _getContributions(page: event.page, size: event.size);
|
||||
|
||||
emit(ContributionsLoaded(
|
||||
contributions: result.contributions,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
size: result.size,
|
||||
totalPages: result.totalPages,
|
||||
));
|
||||
|
||||
AppLogger.blocState('ContributionsBloc', 'ContributionsLoaded', data: {
|
||||
'count': result.contributions.length,
|
||||
'total': result.total,
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur lors du chargement des contributions', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur lors du chargement des contributions', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadContributionById(
|
||||
LoadContributionById event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Chargement de la contribution...'));
|
||||
final contribution = await _getContributionById(event.id);
|
||||
emit(ContributionDetailLoaded(contribution: contribution));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Contribution non trouvée', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCreateContribution(
|
||||
CreateContribution event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Création de la contribution...'));
|
||||
final created = await _createContribution(event.contribution);
|
||||
emit(ContributionCreated(contribution: created));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur lors de la création de la contribution', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onUpdateContribution(
|
||||
UpdateContribution event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Mise à jour de la contribution...'));
|
||||
final updated = await _updateContribution(event.id, event.contribution);
|
||||
emit(ContributionUpdated(contribution: updated));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur lors de la mise à jour', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDeleteContribution(
|
||||
DeleteContribution event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Suppression de la contribution...'));
|
||||
await _deleteContribution(event.id);
|
||||
emit(ContributionDeleted(id: event.id));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur lors de la suppression', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSearchContributions(
|
||||
SearchContributions event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Recherche en cours...'));
|
||||
|
||||
final result = await _repository.getCotisations(
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
membreId: event.membreId,
|
||||
statut: event.statut?.name,
|
||||
type: event.type?.name,
|
||||
annee: event.annee,
|
||||
);
|
||||
|
||||
emit(ContributionsLoaded(
|
||||
contributions: result.contributions,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
size: result.size,
|
||||
totalPages: result.totalPages,
|
||||
));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur lors de la recherche', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadContributionsByMembre(
|
||||
LoadContributionsByMembre event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Chargement des contributions du membre...'));
|
||||
|
||||
final result = await _repository.getCotisations(
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
membreId: event.membreId,
|
||||
);
|
||||
|
||||
emit(ContributionsLoaded(
|
||||
contributions: result.contributions,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
size: result.size,
|
||||
totalPages: result.totalPages,
|
||||
));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur lors du chargement', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadContributionsPayees(
|
||||
LoadContributionsPayees event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Chargement des contributions payées...'));
|
||||
final result = await _repository.getMesCotisations();
|
||||
final payees = result.contributions.where((c) => c.statut == ContributionStatus.payee).toList();
|
||||
emit(ContributionsLoaded(
|
||||
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);
|
||||
emit(ContributionsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadContributionsNonPayees(
|
||||
LoadContributionsNonPayees event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Chargement des contributions non payées...'));
|
||||
final result = await _repository.getMesCotisations();
|
||||
final nonPayees = result.contributions.where((c) => c.statut != ContributionStatus.payee).toList();
|
||||
emit(ContributionsLoaded(
|
||||
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);
|
||||
emit(ContributionsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadContributionsEnRetard(
|
||||
LoadContributionsEnRetard event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Chargement des contributions en retard...'));
|
||||
final result = await _repository.getMesCotisations();
|
||||
final enRetard = result.contributions.where((c) => c.statut == ContributionStatus.enRetard || c.estEnRetard).toList();
|
||||
emit(ContributionsLoaded(
|
||||
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);
|
||||
emit(ContributionsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRecordPayment(
|
||||
RecordPayment event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Enregistrement du paiement...'));
|
||||
|
||||
final updated = await _payContribution(
|
||||
cotisationId: event.contributionId,
|
||||
montant: event.montant,
|
||||
datePaiement: event.datePaiement,
|
||||
methodePaiement: event.methodePaiement.name,
|
||||
numeroPaiement: event.numeroPaiement,
|
||||
referencePaiement: event.referencePaiement,
|
||||
);
|
||||
|
||||
emit(PaymentRecorded(contribution: updated));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur lors de l\'enregistrement du paiement', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadContributionsStats(
|
||||
LoadContributionsStats event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
List<ContributionModel>? preservedList = state is ContributionsLoaded ? (state as ContributionsLoaded).contributions : null;
|
||||
try {
|
||||
// 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() : (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,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Génération des contributions...'));
|
||||
final count = await _repository.genererCotisationsAnnuelles(event.annee);
|
||||
emit(ContributionsGenerated(nombreGenere: count));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSendPaymentReminder(
|
||||
SendPaymentReminder event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Envoi du rappel...'));
|
||||
await _repository.envoyerRappel(event.contributionId);
|
||||
emit(ReminderSent(contributionId: event.contributionId));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
}
|
||||
225
lib/features/contributions/bloc/contributions_event.dart
Normal file
225
lib/features/contributions/bloc/contributions_event.dart
Normal file
@@ -0,0 +1,225 @@
|
||||
/// Événements pour le BLoC des contributions
|
||||
library contributions_event;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../data/models/contribution_model.dart';
|
||||
|
||||
/// Classe de base pour tous les événements de contributions
|
||||
abstract class ContributionsEvent extends Equatable {
|
||||
const ContributionsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Charger la liste des contributions
|
||||
class LoadContributions extends ContributionsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadContributions({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size];
|
||||
}
|
||||
|
||||
/// Charger une contribution par ID
|
||||
class LoadContributionById extends ContributionsEvent {
|
||||
final String id;
|
||||
|
||||
const LoadContributionById({required this.id});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Créer une nouvelle contribution
|
||||
class CreateContribution extends ContributionsEvent {
|
||||
final ContributionModel contribution;
|
||||
|
||||
const CreateContribution({required this.contribution});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contribution];
|
||||
}
|
||||
|
||||
/// Mettre à jour une contribution
|
||||
class UpdateContribution extends ContributionsEvent {
|
||||
final String id;
|
||||
final ContributionModel contribution;
|
||||
|
||||
const UpdateContribution({
|
||||
required this.id,
|
||||
required this.contribution,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, contribution];
|
||||
}
|
||||
|
||||
/// Supprimer une contribution
|
||||
class DeleteContribution extends ContributionsEvent {
|
||||
final String id;
|
||||
|
||||
const DeleteContribution({required this.id});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Rechercher des contributions
|
||||
class SearchContributions extends ContributionsEvent {
|
||||
final String? membreId;
|
||||
final ContributionStatus? statut;
|
||||
final ContributionType? type;
|
||||
final int? annee;
|
||||
final String? query;
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const SearchContributions({
|
||||
this.membreId,
|
||||
this.statut,
|
||||
this.type,
|
||||
this.annee,
|
||||
this.query,
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membreId, statut, type, annee, query, page, size];
|
||||
}
|
||||
|
||||
/// Charger les contributions d'un membre
|
||||
class LoadContributionsByMembre extends ContributionsEvent {
|
||||
final String membreId;
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadContributionsByMembre({
|
||||
required this.membreId,
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membreId, page, size];
|
||||
}
|
||||
|
||||
/// Charger les contributions payées
|
||||
class LoadContributionsPayees extends ContributionsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadContributionsPayees({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size];
|
||||
}
|
||||
|
||||
/// Charger les contributions non payées
|
||||
class LoadContributionsNonPayees extends ContributionsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadContributionsNonPayees({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size];
|
||||
}
|
||||
|
||||
/// Charger les contributions en retard
|
||||
class LoadContributionsEnRetard extends ContributionsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadContributionsEnRetard({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size];
|
||||
}
|
||||
|
||||
/// Enregistrer un paiement
|
||||
class RecordPayment extends ContributionsEvent {
|
||||
final String contributionId;
|
||||
final double montant;
|
||||
final PaymentMethod methodePaiement;
|
||||
final String? numeroPaiement;
|
||||
final String? referencePaiement;
|
||||
final DateTime datePaiement;
|
||||
final String? notes;
|
||||
final String? reference;
|
||||
|
||||
const RecordPayment({
|
||||
required this.contributionId,
|
||||
required this.montant,
|
||||
required this.methodePaiement,
|
||||
this.numeroPaiement,
|
||||
this.referencePaiement,
|
||||
required this.datePaiement,
|
||||
this.notes,
|
||||
this.reference,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
contributionId,
|
||||
montant,
|
||||
methodePaiement,
|
||||
numeroPaiement,
|
||||
referencePaiement,
|
||||
datePaiement,
|
||||
notes,
|
||||
reference,
|
||||
];
|
||||
}
|
||||
|
||||
/// Charger les statistiques des contributions
|
||||
class LoadContributionsStats extends ContributionsEvent {
|
||||
final int? annee;
|
||||
|
||||
const LoadContributionsStats({this.annee});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [annee];
|
||||
}
|
||||
|
||||
/// Générer les contributions annuelles
|
||||
class GenerateAnnualContributions extends ContributionsEvent {
|
||||
final int annee;
|
||||
final double montant;
|
||||
final DateTime dateEcheance;
|
||||
|
||||
const GenerateAnnualContributions({
|
||||
required this.annee,
|
||||
required this.montant,
|
||||
required this.dateEcheance,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [annee, montant, dateEcheance];
|
||||
}
|
||||
|
||||
/// Envoyer un rappel de paiement
|
||||
class SendPaymentReminder extends ContributionsEvent {
|
||||
final String contributionId;
|
||||
|
||||
const SendPaymentReminder({required this.contributionId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contributionId];
|
||||
}
|
||||
|
||||
174
lib/features/contributions/bloc/contributions_state.dart
Normal file
174
lib/features/contributions/bloc/contributions_state.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
/// États pour le BLoC des contributions
|
||||
library contributions_state;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../data/models/contribution_model.dart';
|
||||
|
||||
/// Classe de base pour tous les états de contributions
|
||||
abstract class ContributionsState extends Equatable {
|
||||
const ContributionsState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
class ContributionsInitial extends ContributionsState {
|
||||
const ContributionsInitial();
|
||||
}
|
||||
|
||||
/// État de chargement
|
||||
class ContributionsLoading extends ContributionsState {
|
||||
final String? message;
|
||||
|
||||
const ContributionsLoading({this.message});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État de rafraîchissement
|
||||
class ContributionsRefreshing extends ContributionsState {
|
||||
const ContributionsRefreshing();
|
||||
}
|
||||
|
||||
/// État chargé avec succès
|
||||
class ContributionsLoaded extends ContributionsState {
|
||||
final List<ContributionModel> contributions;
|
||||
final int total;
|
||||
final int page;
|
||||
final int size;
|
||||
final int totalPages;
|
||||
|
||||
const ContributionsLoaded({
|
||||
required this.contributions,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.size,
|
||||
required this.totalPages,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contributions, total, page, size, totalPages];
|
||||
}
|
||||
|
||||
/// État détail d'une contribution chargé
|
||||
class ContributionDetailLoaded extends ContributionsState {
|
||||
final ContributionModel contribution;
|
||||
|
||||
const ContributionDetailLoaded({required this.contribution});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contribution];
|
||||
}
|
||||
|
||||
/// État contribution créée
|
||||
class ContributionCreated extends ContributionsState {
|
||||
final ContributionModel contribution;
|
||||
|
||||
const ContributionCreated({required this.contribution});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contribution];
|
||||
}
|
||||
|
||||
/// État contribution mise à jour
|
||||
class ContributionUpdated extends ContributionsState {
|
||||
final ContributionModel contribution;
|
||||
|
||||
const ContributionUpdated({required this.contribution});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contribution];
|
||||
}
|
||||
|
||||
/// État contribution supprimée
|
||||
class ContributionDeleted extends ContributionsState {
|
||||
final String id;
|
||||
|
||||
const ContributionDeleted({required this.id});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// État paiement enregistré
|
||||
class PaymentRecorded extends ContributionsState {
|
||||
final ContributionModel contribution;
|
||||
|
||||
const PaymentRecorded({required this.contribution});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contribution];
|
||||
}
|
||||
|
||||
/// É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, this.contributions});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [stats, contributions];
|
||||
}
|
||||
|
||||
/// État contributions générées
|
||||
class ContributionsGenerated extends ContributionsState {
|
||||
final int nombreGenere;
|
||||
|
||||
const ContributionsGenerated({required this.nombreGenere});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [nombreGenere];
|
||||
}
|
||||
|
||||
/// État rappel envoyé
|
||||
class ReminderSent extends ContributionsState {
|
||||
final String contributionId;
|
||||
|
||||
const ReminderSent({required this.contributionId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contributionId];
|
||||
}
|
||||
|
||||
/// État d'erreur générique
|
||||
class ContributionsError extends ContributionsState {
|
||||
final String message;
|
||||
final dynamic error;
|
||||
|
||||
const ContributionsError({
|
||||
required this.message,
|
||||
this.error,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, error];
|
||||
}
|
||||
|
||||
/// État d'erreur réseau
|
||||
class ContributionsNetworkError extends ContributionsState {
|
||||
final String message;
|
||||
|
||||
const ContributionsNetworkError({required this.message});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État d'erreur de validation
|
||||
class ContributionsValidationError extends ContributionsState {
|
||||
final String message;
|
||||
final Map<String, String>? fieldErrors;
|
||||
|
||||
const ContributionsValidationError({
|
||||
required this.message,
|
||||
this.fieldErrors,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, fieldErrors];
|
||||
}
|
||||
|
||||
335
lib/features/contributions/data/models/contribution_model.dart
Normal file
335
lib/features/contributions/data/models/contribution_model.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
/// Modèle de données pour les contributions
|
||||
library contribution_model;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'contribution_model.g.dart';
|
||||
|
||||
/// Statut d'une contribution
|
||||
enum ContributionStatus {
|
||||
@JsonValue('PAYEE')
|
||||
payee,
|
||||
@JsonValue('NON_PAYEE')
|
||||
nonPayee,
|
||||
@JsonValue('EN_ATTENTE')
|
||||
enAttente,
|
||||
@JsonValue('EN_RETARD')
|
||||
enRetard,
|
||||
@JsonValue('PARTIELLE')
|
||||
partielle,
|
||||
@JsonValue('ANNULEE')
|
||||
annulee,
|
||||
}
|
||||
|
||||
/// Type de contribution
|
||||
enum ContributionType {
|
||||
@JsonValue('ANNUELLE')
|
||||
annuelle,
|
||||
@JsonValue('MENSUELLE')
|
||||
mensuelle,
|
||||
@JsonValue('TRIMESTRIELLE')
|
||||
trimestrielle,
|
||||
@JsonValue('SEMESTRIELLE')
|
||||
semestrielle,
|
||||
@JsonValue('EXCEPTIONNELLE')
|
||||
exceptionnelle,
|
||||
}
|
||||
|
||||
/// Méthode de paiement
|
||||
enum PaymentMethod {
|
||||
@JsonValue('ESPECES')
|
||||
especes,
|
||||
@JsonValue('CHEQUE')
|
||||
cheque,
|
||||
@JsonValue('VIREMENT')
|
||||
virement,
|
||||
@JsonValue('CARTE_BANCAIRE')
|
||||
carteBancaire,
|
||||
@JsonValue('WAVE_MONEY')
|
||||
waveMoney,
|
||||
@JsonValue('ORANGE_MONEY')
|
||||
orangeMoney,
|
||||
@JsonValue('FREE_MONEY')
|
||||
freeMoney,
|
||||
@JsonValue('MOBILE_MONEY')
|
||||
mobileMoney,
|
||||
@JsonValue('AUTRE')
|
||||
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 {
|
||||
/// Identifiant unique
|
||||
final String? id;
|
||||
|
||||
/// Membre concerné
|
||||
final String membreId;
|
||||
final String? membreNom;
|
||||
final String? membrePrenom;
|
||||
|
||||
/// Organisation
|
||||
final String? organisationId;
|
||||
final String? organisationNom;
|
||||
|
||||
/// Informations de la contribution
|
||||
final ContributionType type;
|
||||
final ContributionStatus statut;
|
||||
final double montant;
|
||||
final double? montantPaye;
|
||||
final String devise;
|
||||
|
||||
/// Dates
|
||||
final DateTime dateEcheance;
|
||||
final DateTime? datePaiement;
|
||||
final DateTime? dateRappel;
|
||||
|
||||
/// Paiement
|
||||
final PaymentMethod? methodePaiement;
|
||||
final String? numeroPaiement;
|
||||
final String? referencePaiement;
|
||||
|
||||
/// Période
|
||||
final int annee;
|
||||
final int? mois;
|
||||
final int? trimestre;
|
||||
final int? semestre;
|
||||
|
||||
/// Informations complémentaires
|
||||
final String? description;
|
||||
final String? notes;
|
||||
final String? recu;
|
||||
|
||||
/// Métadonnées
|
||||
final DateTime? dateCreation;
|
||||
final DateTime? dateModification;
|
||||
final String? creeParId;
|
||||
final String? modifieParId;
|
||||
|
||||
const ContributionModel({
|
||||
this.id,
|
||||
required this.membreId,
|
||||
this.membreNom,
|
||||
this.membrePrenom,
|
||||
this.organisationId,
|
||||
this.organisationNom,
|
||||
this.type = ContributionType.annuelle,
|
||||
this.statut = ContributionStatus.nonPayee,
|
||||
required this.montant,
|
||||
this.montantPaye,
|
||||
this.devise = 'XOF',
|
||||
required this.dateEcheance,
|
||||
this.datePaiement,
|
||||
this.dateRappel,
|
||||
this.methodePaiement,
|
||||
this.numeroPaiement,
|
||||
this.referencePaiement,
|
||||
required this.annee,
|
||||
this.mois,
|
||||
this.trimestre,
|
||||
this.semestre,
|
||||
this.description,
|
||||
this.notes,
|
||||
this.recu,
|
||||
this.dateCreation,
|
||||
this.dateModification,
|
||||
this.creeParId,
|
||||
this.modifieParId,
|
||||
});
|
||||
|
||||
/// Désérialisation depuis JSON
|
||||
factory ContributionModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ContributionModelFromJson(json);
|
||||
|
||||
/// Sérialisation vers JSON
|
||||
Map<String, dynamic> toJson() => _$ContributionModelToJson(this);
|
||||
|
||||
/// Copie avec modifications
|
||||
ContributionModel copyWith({
|
||||
String? id,
|
||||
String? membreId,
|
||||
String? membreNom,
|
||||
String? membrePrenom,
|
||||
String? organisationId,
|
||||
String? organisationNom,
|
||||
ContributionType? type,
|
||||
ContributionStatus? statut,
|
||||
double? montant,
|
||||
double? montantPaye,
|
||||
String? devise,
|
||||
DateTime? dateEcheance,
|
||||
DateTime? datePaiement,
|
||||
DateTime? dateRappel,
|
||||
PaymentMethod? methodePaiement,
|
||||
String? numeroPaiement,
|
||||
String? referencePaiement,
|
||||
int? annee,
|
||||
int? mois,
|
||||
int? trimestre,
|
||||
int? semestre,
|
||||
String? description,
|
||||
String? notes,
|
||||
String? recu,
|
||||
DateTime? dateCreation,
|
||||
DateTime? dateModification,
|
||||
String? creeParId,
|
||||
String? modifieParId,
|
||||
}) {
|
||||
return ContributionModel(
|
||||
id: id ?? this.id,
|
||||
membreId: membreId ?? this.membreId,
|
||||
membreNom: membreNom ?? this.membreNom,
|
||||
membrePrenom: membrePrenom ?? this.membrePrenom,
|
||||
organisationId: organisationId ?? this.organisationId,
|
||||
organisationNom: organisationNom ?? this.organisationNom,
|
||||
type: type ?? this.type,
|
||||
statut: statut ?? this.statut,
|
||||
montant: montant ?? this.montant,
|
||||
montantPaye: montantPaye ?? this.montantPaye,
|
||||
devise: devise ?? this.devise,
|
||||
dateEcheance: dateEcheance ?? this.dateEcheance,
|
||||
datePaiement: datePaiement ?? this.datePaiement,
|
||||
dateRappel: dateRappel ?? this.dateRappel,
|
||||
methodePaiement: methodePaiement ?? this.methodePaiement,
|
||||
numeroPaiement: numeroPaiement ?? this.numeroPaiement,
|
||||
referencePaiement: referencePaiement ?? this.referencePaiement,
|
||||
annee: annee ?? this.annee,
|
||||
mois: mois ?? this.mois,
|
||||
trimestre: trimestre ?? this.trimestre,
|
||||
semestre: semestre ?? this.semestre,
|
||||
description: description ?? this.description,
|
||||
notes: notes ?? this.notes,
|
||||
recu: recu ?? this.recu,
|
||||
dateCreation: dateCreation ?? this.dateCreation,
|
||||
dateModification: dateModification ?? this.dateModification,
|
||||
creeParId: creeParId ?? this.creeParId,
|
||||
modifieParId: modifieParId ?? this.modifieParId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Nom complet du membre
|
||||
String get membreNomComplet {
|
||||
if (membreNom != null && membrePrenom != null) {
|
||||
return '$membrePrenom $membreNom';
|
||||
}
|
||||
return membreNom ?? membrePrenom ?? 'Membre inconnu';
|
||||
}
|
||||
|
||||
/// Montant restant à payer
|
||||
double get montantRestant {
|
||||
if (montantPaye == null) return montant;
|
||||
return montant - montantPaye!;
|
||||
}
|
||||
|
||||
/// Pourcentage payé
|
||||
double get pourcentagePaye {
|
||||
if (montantPaye == null || montant == 0) return 0;
|
||||
return (montantPaye! / montant) * 100;
|
||||
}
|
||||
|
||||
/// Vérifie si la contribution est payée
|
||||
bool get estPayee => statut == ContributionStatus.payee;
|
||||
|
||||
/// Vérifie si la contribution est en retard
|
||||
bool get estEnRetard {
|
||||
if (estPayee) return false;
|
||||
return DateTime.now().isAfter(dateEcheance);
|
||||
}
|
||||
|
||||
/// Nombre de jours avant/après l'échéance
|
||||
int get joursAvantEcheance {
|
||||
return dateEcheance.difference(DateTime.now()).inDays;
|
||||
}
|
||||
|
||||
/// Libellé de la période
|
||||
String get libellePeriode {
|
||||
switch (type) {
|
||||
case ContributionType.annuelle:
|
||||
return 'Année $annee';
|
||||
case ContributionType.mensuelle:
|
||||
if (mois != null) {
|
||||
return '${_getNomMois(mois!)} $annee';
|
||||
}
|
||||
return 'Année $annee';
|
||||
case ContributionType.trimestrielle:
|
||||
if (trimestre != null) {
|
||||
return 'T$trimestre $annee';
|
||||
}
|
||||
return 'Année $annee';
|
||||
case ContributionType.semestrielle:
|
||||
if (semestre != null) {
|
||||
return 'S$semestre $annee';
|
||||
}
|
||||
return 'Année $annee';
|
||||
case ContributionType.exceptionnelle:
|
||||
return 'Exceptionnelle $annee';
|
||||
}
|
||||
}
|
||||
|
||||
/// Nom du mois
|
||||
String _getNomMois(int mois) {
|
||||
const moisFr = [
|
||||
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
|
||||
];
|
||||
if (mois >= 1 && mois <= 12) {
|
||||
return moisFr[mois - 1];
|
||||
}
|
||||
return 'Mois $mois';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
membreId,
|
||||
membreNom,
|
||||
membrePrenom,
|
||||
organisationId,
|
||||
organisationNom,
|
||||
type,
|
||||
statut,
|
||||
montant,
|
||||
montantPaye,
|
||||
devise,
|
||||
dateEcheance,
|
||||
datePaiement,
|
||||
dateRappel,
|
||||
methodePaiement,
|
||||
numeroPaiement,
|
||||
referencePaiement,
|
||||
annee,
|
||||
mois,
|
||||
trimestre,
|
||||
semestre,
|
||||
description,
|
||||
notes,
|
||||
recu,
|
||||
dateCreation,
|
||||
dateModification,
|
||||
creeParId,
|
||||
modifieParId,
|
||||
];
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ContributionModel(id: $id, membre: $membreNomComplet, montant: $montant $devise, statut: $statut)';
|
||||
}
|
||||
|
||||
112
lib/features/contributions/data/models/contribution_model.g.dart
Normal file
112
lib/features/contributions/data/models/contribution_model.g.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'contribution_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
ContributionModel _$ContributionModelFromJson(Map<String, dynamic> json) =>
|
||||
ContributionModel(
|
||||
id: json['id'] as String?,
|
||||
membreId: json['membreId'] as String,
|
||||
membreNom: json['membreNom'] as String?,
|
||||
membrePrenom: json['membrePrenom'] as String?,
|
||||
organisationId: json['organisationId'] as String?,
|
||||
organisationNom: json['organisationNom'] as String?,
|
||||
type: $enumDecodeNullable(_$ContributionTypeEnumMap, json['type']) ??
|
||||
ContributionType.annuelle,
|
||||
statut:
|
||||
$enumDecodeNullable(_$ContributionStatusEnumMap, json['statut']) ??
|
||||
ContributionStatus.nonPayee,
|
||||
montant: (json['montant'] as num).toDouble(),
|
||||
montantPaye: (json['montantPaye'] as num?)?.toDouble(),
|
||||
devise: json['devise'] as String? ?? 'XOF',
|
||||
dateEcheance: DateTime.parse(json['dateEcheance'] as String),
|
||||
datePaiement: json['datePaiement'] == null
|
||||
? null
|
||||
: DateTime.parse(json['datePaiement'] as String),
|
||||
dateRappel: json['dateRappel'] == null
|
||||
? null
|
||||
: DateTime.parse(json['dateRappel'] as String),
|
||||
methodePaiement:
|
||||
$enumDecodeNullable(_$PaymentMethodEnumMap, json['methodePaiement']),
|
||||
numeroPaiement: json['numeroPaiement'] as String?,
|
||||
referencePaiement: json['referencePaiement'] as String?,
|
||||
annee: (json['annee'] as num).toInt(),
|
||||
mois: (json['mois'] as num?)?.toInt(),
|
||||
trimestre: (json['trimestre'] as num?)?.toInt(),
|
||||
semestre: (json['semestre'] as num?)?.toInt(),
|
||||
description: json['description'] as String?,
|
||||
notes: json['notes'] as String?,
|
||||
recu: json['recu'] as String?,
|
||||
dateCreation: json['dateCreation'] == null
|
||||
? null
|
||||
: DateTime.parse(json['dateCreation'] as String),
|
||||
dateModification: json['dateModification'] == null
|
||||
? null
|
||||
: DateTime.parse(json['dateModification'] as String),
|
||||
creeParId: json['creeParId'] as String?,
|
||||
modifieParId: json['modifieParId'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ContributionModelToJson(ContributionModel instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'membreId': instance.membreId,
|
||||
'membreNom': instance.membreNom,
|
||||
'membrePrenom': instance.membrePrenom,
|
||||
'organisationId': instance.organisationId,
|
||||
'organisationNom': instance.organisationNom,
|
||||
'type': _$ContributionTypeEnumMap[instance.type]!,
|
||||
'statut': _$ContributionStatusEnumMap[instance.statut]!,
|
||||
'montant': instance.montant,
|
||||
'montantPaye': instance.montantPaye,
|
||||
'devise': instance.devise,
|
||||
'dateEcheance': instance.dateEcheance.toIso8601String(),
|
||||
'datePaiement': instance.datePaiement?.toIso8601String(),
|
||||
'dateRappel': instance.dateRappel?.toIso8601String(),
|
||||
'methodePaiement': _$PaymentMethodEnumMap[instance.methodePaiement],
|
||||
'numeroPaiement': instance.numeroPaiement,
|
||||
'referencePaiement': instance.referencePaiement,
|
||||
'annee': instance.annee,
|
||||
'mois': instance.mois,
|
||||
'trimestre': instance.trimestre,
|
||||
'semestre': instance.semestre,
|
||||
'description': instance.description,
|
||||
'notes': instance.notes,
|
||||
'recu': instance.recu,
|
||||
'dateCreation': instance.dateCreation?.toIso8601String(),
|
||||
'dateModification': instance.dateModification?.toIso8601String(),
|
||||
'creeParId': instance.creeParId,
|
||||
'modifieParId': instance.modifieParId,
|
||||
};
|
||||
|
||||
const _$ContributionTypeEnumMap = {
|
||||
ContributionType.annuelle: 'ANNUELLE',
|
||||
ContributionType.mensuelle: 'MENSUELLE',
|
||||
ContributionType.trimestrielle: 'TRIMESTRIELLE',
|
||||
ContributionType.semestrielle: 'SEMESTRIELLE',
|
||||
ContributionType.exceptionnelle: 'EXCEPTIONNELLE',
|
||||
};
|
||||
|
||||
const _$ContributionStatusEnumMap = {
|
||||
ContributionStatus.payee: 'PAYEE',
|
||||
ContributionStatus.nonPayee: 'NON_PAYEE',
|
||||
ContributionStatus.enAttente: 'EN_ATTENTE',
|
||||
ContributionStatus.enRetard: 'EN_RETARD',
|
||||
ContributionStatus.partielle: 'PARTIELLE',
|
||||
ContributionStatus.annulee: 'ANNULEE',
|
||||
};
|
||||
|
||||
const _$PaymentMethodEnumMap = {
|
||||
PaymentMethod.especes: 'ESPECES',
|
||||
PaymentMethod.cheque: 'CHEQUE',
|
||||
PaymentMethod.virement: 'VIREMENT',
|
||||
PaymentMethod.carteBancaire: 'CARTE_BANCAIRE',
|
||||
PaymentMethod.waveMoney: 'WAVE_MONEY',
|
||||
PaymentMethod.orangeMoney: 'ORANGE_MONEY',
|
||||
PaymentMethod.freeMoney: 'FREE_MONEY',
|
||||
PaymentMethod.mobileMoney: 'MOBILE_MONEY',
|
||||
PaymentMethod.autre: 'AUTRE',
|
||||
};
|
||||
@@ -0,0 +1,404 @@
|
||||
/// Implémentation du repository des cotisations via l'API backend
|
||||
library contribution_repository_impl;
|
||||
|
||||
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';
|
||||
|
||||
/// 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';
|
||||
|
||||
ContributionRepositoryImpl(this._apiClient);
|
||||
|
||||
/// 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,
|
||||
String? membreId,
|
||||
String? statut,
|
||||
String? type,
|
||||
int? annee,
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{
|
||||
'page': page,
|
||||
'size': size,
|
||||
};
|
||||
if (membreId != null) queryParams['membreId'] = membreId;
|
||||
if (statut != null) queryParams['statut'] = statut;
|
||||
if (type != null) queryParams['type'] = type;
|
||||
if (annee != null) queryParams['annee'] = annee;
|
||||
|
||||
final response = await _apiClient.get(
|
||||
_baseUrl,
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data;
|
||||
if (data is List) {
|
||||
final contributions = data
|
||||
.map((json) => ContributionModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
return ContributionPageResult(
|
||||
contributions: contributions,
|
||||
total: contributions.length,
|
||||
page: page,
|
||||
size: size,
|
||||
totalPages: 1,
|
||||
);
|
||||
} else if (data is Map<String, dynamic>) {
|
||||
final List<dynamic> content = data['content'] ?? data['items'] ?? [];
|
||||
final contributions = content
|
||||
.map((json) => ContributionModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
return ContributionPageResult(
|
||||
contributions: contributions,
|
||||
total: data['totalElements'] ?? data['total'] ?? contributions.length,
|
||||
page: data['number'] ?? page,
|
||||
size: data['size'] ?? size,
|
||||
totalPages: data['totalPages'] ?? 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw Exception('Erreur lors de la récupération des cotisations: ${response.statusCode}');
|
||||
}
|
||||
|
||||
/// Récupère une cotisation par ID
|
||||
Future<ContributionModel> getCotisationById(String id) async {
|
||||
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 (payload conforme au backend CreateCotisationRequest)
|
||||
Future<ContributionModel> createCotisation(ContributionModel contribution) async {
|
||||
final body = _toCreateCotisationRequest(contribution);
|
||||
final response = await _apiClient.post(_baseUrl, data: body);
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
final data = Map<String, dynamic>.from(response.data as Map<String, dynamic>);
|
||||
_normalizeCotisationResponse(data);
|
||||
return ContributionModel.fromJson(data);
|
||||
}
|
||||
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 _apiClient.put('$_baseUrl/$id', data: contribution.toJson());
|
||||
if (response.statusCode == 200) {
|
||||
return ContributionModel.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
throw Exception('Erreur lors de la mise à jour: ${response.statusCode}');
|
||||
}
|
||||
|
||||
/// Supprime une cotisation
|
||||
Future<void> deleteCotisation(String id) async {
|
||||
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, {
|
||||
required double montant,
|
||||
required DateTime datePaiement,
|
||||
required String methodePaiement,
|
||||
String? numeroPaiement,
|
||||
String? referencePaiement,
|
||||
}) async {
|
||||
final response = await _apiClient.post(
|
||||
'$_baseUrl/$cotisationId/paiement',
|
||||
data: {
|
||||
'montant': montant,
|
||||
'datePaiement': datePaiement.toIso8601String(),
|
||||
'methodePaiement': methodePaiement,
|
||||
if (numeroPaiement != null) 'numeroPaiement': numeroPaiement,
|
||||
if (referencePaiement != null) 'referencePaiement': referencePaiement,
|
||||
},
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
return ContributionModel.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
throw Exception('Erreur lors de l\'enregistrement du paiement: ${response.statusCode}');
|
||||
}
|
||||
|
||||
/// 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 _apiClient.get('$_baseUrl/statistiques');
|
||||
if (response.statusCode == 200) {
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
throw Exception('Erreur lors de la récupération des statistiques');
|
||||
}
|
||||
|
||||
/// Envoie un rappel de paiement
|
||||
Future<void> envoyerRappel(String cotisationId) async {
|
||||
final response = await _apiClient.post('$_baseUrl/$cotisationId/rappel');
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Erreur lors de l\'envoi du rappel');
|
||||
}
|
||||
}
|
||||
|
||||
/// Génère les cotisations annuelles
|
||||
Future<int> genererCotisationsAnnuelles(int annee) async {
|
||||
final response = await _apiClient.post(
|
||||
'$_baseUrl/generer',
|
||||
data: {'annee': annee},
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
return response.data['nombreGenere'] ?? 0;
|
||||
}
|
||||
throw Exception('Erreur lors de la génération');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
final int total;
|
||||
final int page;
|
||||
final int size;
|
||||
final int totalPages;
|
||||
|
||||
const ContributionPageResult({
|
||||
required this.contributions,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.size,
|
||||
required this.totalPages,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.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 '../../../../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 'package:unionflow_mobile_apps/features/contributions/presentation/pages/mes_statistiques_cotisations_page.dart';
|
||||
|
||||
/// Page de gestion des contributions - Version Design System
|
||||
class ContributionsPage extends StatefulWidget {
|
||||
const ContributionsPage({super.key});
|
||||
|
||||
@override
|
||||
State<ContributionsPage> createState() => _ContributionsPageState();
|
||||
}
|
||||
|
||||
class _ContributionsPageState extends State<ContributionsPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA', decimalDigits: 0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadContributions() {
|
||||
final currentTab = _tabController.index;
|
||||
switch (currentTab) {
|
||||
case 0:
|
||||
context.read<ContributionsBloc>().add(const LoadContributions());
|
||||
break;
|
||||
case 1:
|
||||
context.read<ContributionsBloc>().add(const LoadContributionsPayees());
|
||||
break;
|
||||
case 2:
|
||||
context.read<ContributionsBloc>().add(const LoadContributionsNonPayees());
|
||||
break;
|
||||
case 3:
|
||||
context.read<ContributionsBloc>().add(const LoadContributionsEnRetard());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ColorTokens.background,
|
||||
appBar: UFAppBar(
|
||||
title: 'Cotisations',
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bar_chart, size: 20),
|
||||
onPressed: () => _showStats(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline, size: 20),
|
||||
onPressed: () => _showCreateDialog(),
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContributionsList() {
|
||||
return BlocBuilder<ContributionsBloc, ContributionsState>(
|
||||
builder: (context, state) {
|
||||
if (state is ContributionsLoading) {
|
||||
return const Center(child: AppLoadingWidget());
|
||||
}
|
||||
|
||||
if (state is ContributionsError) {
|
||||
return Center(
|
||||
child: AppErrorWidget(
|
||||
message: state.message,
|
||||
onRetry: _loadContributions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is ContributionsLoaded) {
|
||||
return _buildListOrEmpty(state.contributions);
|
||||
}
|
||||
|
||||
// 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 _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 _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:
|
||||
return const InfoBadge(text: 'PAYÉE', backgroundColor: Color(0xFFE3F9E5), textColor: ColorTokens.success);
|
||||
case ContributionStatus.nonPayee:
|
||||
case ContributionStatus.enAttente:
|
||||
return const InfoBadge(text: 'DUE', backgroundColor: Color(0xFFFFF4E5), textColor: ColorTokens.warning);
|
||||
case ContributionStatus.partielle:
|
||||
return const InfoBadge(text: 'PARTIELLE', backgroundColor: Color(0xFFE5F1FF), textColor: ColorTokens.info);
|
||||
case ContributionStatus.annulee:
|
||||
return InfoBadge.neutral('ANNULÉE');
|
||||
default:
|
||||
return InfoBadge.neutral(statut.name.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
void _showContributionDetails(ContributionModel contribution) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
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),
|
||||
],
|
||||
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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value, {bool isCritical = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.xs),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
|
||||
Text(value, style: AppTypography.bodyTextSmall.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isCritical ? ColorTokens.error : ColorTokens.onSurface,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPaymentDialog(ContributionModel contribution) {
|
||||
final contributionsBloc = context.read<ContributionsBloc>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => BlocProvider.value(
|
||||
value: contributionsBloc,
|
||||
child: PaymentDialog(cotisation: contribution),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateDialog() {
|
||||
final contributionsBloc = context.read<ContributionsBloc>();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => BlocProvider.value(
|
||||
value: contributionsBloc,
|
||||
child: const CreateContributionDialog(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showStats() {
|
||||
final contributionsBloc = context.read<ContributionsBloc>();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => BlocProvider.value(
|
||||
value: contributionsBloc,
|
||||
child: const MesStatistiquesCotisationsPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/// Wrapper BLoC pour la page des cotisations
|
||||
library cotisations_page_wrapper;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.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 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 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();
|
||||
}
|
||||
|
||||
@@ -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: 'Dû',
|
||||
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, 'Dû', _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 l’axe 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 l’axe X : "Jan 25", "Avr 25" — peu de caractères.
|
||||
String _formatAxisPeriod(int month, int year) {
|
||||
final shortYear = year % 100;
|
||||
return '${_monthShort(month)} $shortYear';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/// Dialogue de création de contribution
|
||||
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/data/models/membre_complete_model.dart';
|
||||
import '../../../profile/domain/repositories/profile_repository.dart';
|
||||
|
||||
|
||||
class CreateContributionDialog extends StatefulWidget {
|
||||
const CreateContributionDialog({super.key});
|
||||
|
||||
@override
|
||||
State<CreateContributionDialog> createState() => _CreateContributionDialogState();
|
||||
}
|
||||
|
||||
class _CreateContributionDialogState extends State<CreateContributionDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _montantController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
|
||||
ContributionType _selectedType = ContributionType.mensuelle;
|
||||
MembreCompletModel? _me;
|
||||
DateTime _dateEcheance = DateTime.now().add(const Duration(days: 30));
|
||||
bool _isLoading = false;
|
||||
bool _isInitLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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
|
||||
void dispose() {
|
||||
_montantController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Nouvelle contribution'),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.8,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 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
|
||||
DropdownButtonFormField<ContributionType>(
|
||||
value: _selectedType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Type de contribution',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: ContributionType.values.map((type) {
|
||||
return DropdownMenuItem(
|
||||
value: type,
|
||||
child: Text(_getTypeLabel(type)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedType = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Montant
|
||||
TextFormField(
|
||||
controller: _montantController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Montant (FCFA)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.attach_money),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez saisir un montant';
|
||||
}
|
||||
if (double.tryParse(value) == null) {
|
||||
return 'Montant invalide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Date d'échéance
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _dateEcheance,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (date != null) {
|
||||
setState(() {
|
||||
_dateEcheance = date;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Date d\'échéance',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.calendar_today),
|
||||
),
|
||||
child: Text(
|
||||
DateFormat('dd/MM/yyyy').format(_dateEcheance),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Description
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optionnel)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.description),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _createContribution,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Créer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getTypeLabel(ContributionType type) {
|
||||
switch (type) {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createContribution() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_me == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Profil non chargé'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_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: membre.id!,
|
||||
membreNom: membre.nom,
|
||||
membrePrenom: membre.prenom,
|
||||
organisationId: organisationId,
|
||||
organisationNom: organisationNom,
|
||||
type: _selectedType,
|
||||
annee: DateTime.now().year,
|
||||
montant: double.parse(_montantController.text),
|
||||
dateEcheance: _dateEcheance,
|
||||
description: _descriptionController.text.isNotEmpty ? _descriptionController.text : null,
|
||||
statut: ContributionStatus.nonPayee,
|
||||
dateCreation: DateTime.now(),
|
||||
dateModification: DateTime.now(),
|
||||
);
|
||||
|
||||
context.read<ContributionsBloc>().add(CreateContribution(contribution: contribution));
|
||||
Navigator.pop(context);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Contribution créée avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
/// Dialogue de 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 {
|
||||
final ContributionModel cotisation;
|
||||
|
||||
const PaymentDialog({
|
||||
super.key,
|
||||
required this.cotisation,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentDialog> createState() => _PaymentDialogState();
|
||||
}
|
||||
|
||||
class _PaymentDialogState extends State<PaymentDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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();
|
||||
_montantController.text = widget.cotisation.montantRestant.toStringAsFixed(0);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_montantController.dispose();
|
||||
_referenceController.dispose();
|
||||
_notesController.dispose();
|
||||
_wavePhoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width * 0.9,
|
||||
constraints: const BoxConstraints(maxHeight: 500),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// En-tête
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF10B981),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topRight: Radius.circular(4),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.payment, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Enregistrer un paiement',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Informations de la cotisation
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.grey[100],
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.cotisation.membreNomComplet,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.cotisation.libellePeriode,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Montant total:',
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
Text(
|
||||
'${NumberFormat('#,###').format(widget.cotisation.montant)} ${widget.cotisation.devise}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Déjà payé:',
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
Text(
|
||||
'${NumberFormat('#,###').format(widget.cotisation.montantPaye ?? 0)} ${widget.cotisation.devise}',
|
||||
style: const TextStyle(color: Colors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Restant:',
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
Text(
|
||||
'${NumberFormat('#,###').format(widget.cotisation.montantRestant)} ${widget.cotisation.devise}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Formulaire
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Montant
|
||||
TextFormField(
|
||||
controller: _montantController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Montant à payer *',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.attach_money),
|
||||
suffixText: widget.cotisation.devise,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Le montant est obligatoire';
|
||||
}
|
||||
final montant = double.tryParse(value);
|
||||
if (montant == null || montant <= 0) {
|
||||
return 'Montant invalide';
|
||||
}
|
||||
if (montant > widget.cotisation.montantRestant) {
|
||||
return 'Montant supérieur au restant dû';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Méthode de paiement
|
||||
DropdownButtonFormField<PaymentMethod>(
|
||||
value: _selectedMethode,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Méthode de paiement *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.payment),
|
||||
),
|
||||
items: PaymentMethod.values.map((methode) {
|
||||
return DropdownMenuItem<PaymentMethod>(
|
||||
value: methode,
|
||||
child: Row(
|
||||
children: [
|
||||
PaymentMethodIcon(
|
||||
paymentMethodCode: methode.code,
|
||||
width: 24,
|
||||
height: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(_getMethodeLabel(methode)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedMethode = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
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),
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Date de paiement *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.calendar_today),
|
||||
),
|
||||
child: Text(
|
||||
DateFormat('dd/MM/yyyy').format(_datePaiement),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Référence
|
||||
TextFormField(
|
||||
controller: _referenceController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Référence de transaction',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.receipt),
|
||||
hintText: 'Ex: TRX123456789',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Notes
|
||||
TextFormField(
|
||||
controller: _notesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes (optionnel)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.note),
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Boutons d'action
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
border: Border(top: BorderSide(color: Colors.grey[300]!)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _waveLoading ? null : _submitForm,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF10B981),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getMethodeIcon(PaymentMethod methode) {
|
||||
switch (methode) {
|
||||
case PaymentMethod.waveMoney:
|
||||
return Icons.phone_android;
|
||||
case PaymentMethod.orangeMoney:
|
||||
return Icons.phone_iphone;
|
||||
case PaymentMethod.freeMoney:
|
||||
return Icons.smartphone;
|
||||
case PaymentMethod.mobileMoney:
|
||||
return Icons.mobile_friendly;
|
||||
case PaymentMethod.especes:
|
||||
return Icons.money;
|
||||
case PaymentMethod.cheque:
|
||||
return Icons.receipt_long;
|
||||
case PaymentMethod.virement:
|
||||
return Icons.account_balance;
|
||||
case PaymentMethod.carteBancaire:
|
||||
return Icons.credit_card;
|
||||
case PaymentMethod.autre:
|
||||
return Icons.more_horiz;
|
||||
}
|
||||
}
|
||||
|
||||
String _getMethodeLabel(PaymentMethod methode) {
|
||||
switch (methode) {
|
||||
case PaymentMethod.waveMoney:
|
||||
return 'Wave Money';
|
||||
case PaymentMethod.orangeMoney:
|
||||
return 'Orange Money';
|
||||
case PaymentMethod.freeMoney:
|
||||
return 'Free Money';
|
||||
case PaymentMethod.especes:
|
||||
return 'Espèces';
|
||||
case PaymentMethod.cheque:
|
||||
return 'Chèque';
|
||||
case PaymentMethod.virement:
|
||||
return 'Virement bancaire';
|
||||
case PaymentMethod.carteBancaire:
|
||||
return 'Carte bancaire';
|
||||
case PaymentMethod.mobileMoney:
|
||||
return 'Mobile Money (autre)';
|
||||
case PaymentMethod.autre:
|
||||
return 'Autre';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectDate(BuildContext context) async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _datePaiement,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (picked != null && picked != _datePaiement) {
|
||||
setState(() {
|
||||
_datePaiement = picked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitForm() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
if (_selectedMethode == PaymentMethod.waveMoney) {
|
||||
await _submitWavePayment();
|
||||
return;
|
||||
}
|
||||
|
||||
final montant = double.parse(_montantController.text);
|
||||
// L’UI 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('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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user