Authentification stable - WIP
This commit is contained in:
@@ -1,134 +0,0 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../core/services/api_service.dart';
|
||||
import '../../../../core/services/cache_service.dart';
|
||||
import '../../../cotisations/domain/repositories/cotisation_repository.dart';
|
||||
|
||||
/// Implémentation du repository des cotisations
|
||||
/// Utilise ApiService pour communiquer avec le backend et CacheService pour le cache local
|
||||
@LazySingleton(as: CotisationRepository)
|
||||
class CotisationRepositoryImpl implements CotisationRepository {
|
||||
final ApiService _apiService;
|
||||
final CacheService _cacheService;
|
||||
|
||||
CotisationRepositoryImpl(this._apiService, this._cacheService);
|
||||
|
||||
@override
|
||||
Future<List<CotisationModel>> getCotisations({int page = 0, int size = 20}) async {
|
||||
return await _apiService.getCotisations(page: page, size: size);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CotisationModel> getCotisationById(String id) async {
|
||||
return await _apiService.getCotisationById(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CotisationModel> getCotisationByReference(String numeroReference) async {
|
||||
return await _apiService.getCotisationByReference(numeroReference);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CotisationModel> createCotisation(CotisationModel cotisation) async {
|
||||
return await _apiService.createCotisation(cotisation);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CotisationModel> updateCotisation(String id, CotisationModel cotisation) async {
|
||||
return await _apiService.updateCotisation(id, cotisation);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteCotisation(String id) async {
|
||||
return await _apiService.deleteCotisation(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CotisationModel>> getCotisationsByMembre(String membreId, {int page = 0, int size = 20}) async {
|
||||
return await _apiService.getCotisationsByMembre(membreId, page: page, size: size);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CotisationModel>> getCotisationsByStatut(String statut, {int page = 0, int size = 20}) async {
|
||||
return await _apiService.getCotisationsByStatut(statut, page: page, size: size);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CotisationModel>> getCotisationsEnRetard({int page = 0, int size = 20}) async {
|
||||
return await _apiService.getCotisationsEnRetard(page: page, size: size);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CotisationModel>> rechercherCotisations({
|
||||
String? membreId,
|
||||
String? statut,
|
||||
String? typeCotisation,
|
||||
int? annee,
|
||||
int? mois,
|
||||
int page = 0,
|
||||
int size = 20,
|
||||
}) async {
|
||||
return await _apiService.rechercherCotisations(
|
||||
membreId: membreId,
|
||||
statut: statut,
|
||||
typeCotisation: typeCotisation,
|
||||
annee: annee,
|
||||
mois: mois,
|
||||
page: page,
|
||||
size: size,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> getCotisationsStats() async {
|
||||
// Essayer de récupérer depuis le cache d'abord
|
||||
final cachedStats = await _cacheService.getCotisationsStats();
|
||||
if (cachedStats != null) {
|
||||
return cachedStats.toJson();
|
||||
}
|
||||
|
||||
try {
|
||||
final stats = await _apiService.getCotisationsStats();
|
||||
|
||||
// Sauvegarder en cache si possible
|
||||
// Note: Conversion nécessaire selon la structure des stats du backend
|
||||
// await _cacheService.saveCotisationsStats(statsModel);
|
||||
|
||||
return stats;
|
||||
} catch (e) {
|
||||
// En cas d'erreur, retourner le cache si disponible
|
||||
if (cachedStats != null) {
|
||||
return cachedStats.toJson();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalide tous les caches de listes de cotisations
|
||||
Future<void> _invalidateListCaches() async {
|
||||
// Nettoyer les caches de listes paginées
|
||||
final keys = ['cotisations_page_0_size_20', 'cotisations_cache'];
|
||||
for (final key in keys) {
|
||||
await _cacheService.clearCotisations(key: key);
|
||||
}
|
||||
|
||||
// Nettoyer le cache des statistiques
|
||||
await _cacheService.clearCotisationsStats();
|
||||
}
|
||||
|
||||
/// Force la synchronisation avec le serveur
|
||||
Future<void> forceSync() async {
|
||||
await _cacheService.clearAllCotisationsCache();
|
||||
await _cacheService.updateLastSyncTimestamp();
|
||||
}
|
||||
|
||||
/// Vérifie si une synchronisation est nécessaire
|
||||
bool needsSync() {
|
||||
return _cacheService.needsSync();
|
||||
}
|
||||
|
||||
/// Retourne des informations sur le cache
|
||||
Map<String, dynamic> getCacheInfo() {
|
||||
return _cacheService.getCacheInfo();
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
|
||||
/// Interface du repository des cotisations
|
||||
/// Définit les contrats pour l'accès aux données des cotisations
|
||||
abstract class CotisationRepository {
|
||||
/// Récupère la liste de toutes les cotisations avec pagination
|
||||
Future<List<CotisationModel>> getCotisations({int page = 0, int size = 20});
|
||||
|
||||
/// Récupère une cotisation par son ID
|
||||
Future<CotisationModel> getCotisationById(String id);
|
||||
|
||||
/// Récupère une cotisation par son numéro de référence
|
||||
Future<CotisationModel> getCotisationByReference(String numeroReference);
|
||||
|
||||
/// Crée une nouvelle cotisation
|
||||
Future<CotisationModel> createCotisation(CotisationModel cotisation);
|
||||
|
||||
/// Met à jour une cotisation existante
|
||||
Future<CotisationModel> updateCotisation(String id, CotisationModel cotisation);
|
||||
|
||||
/// Supprime une cotisation
|
||||
Future<void> deleteCotisation(String id);
|
||||
|
||||
/// Récupère les cotisations d'un membre
|
||||
Future<List<CotisationModel>> getCotisationsByMembre(String membreId, {int page = 0, int size = 20});
|
||||
|
||||
/// Récupère les cotisations par statut
|
||||
Future<List<CotisationModel>> getCotisationsByStatut(String statut, {int page = 0, int size = 20});
|
||||
|
||||
/// Récupère les cotisations en retard
|
||||
Future<List<CotisationModel>> getCotisationsEnRetard({int page = 0, int size = 20});
|
||||
|
||||
/// Recherche avancée de cotisations
|
||||
Future<List<CotisationModel>> rechercherCotisations({
|
||||
String? membreId,
|
||||
String? statut,
|
||||
String? typeCotisation,
|
||||
int? annee,
|
||||
int? mois,
|
||||
int page = 0,
|
||||
int size = 20,
|
||||
});
|
||||
|
||||
/// Récupère les statistiques des cotisations
|
||||
Future<Map<String, dynamic>> getCotisationsStats();
|
||||
}
|
||||
@@ -1,730 +0,0 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../core/models/payment_model.dart';
|
||||
import '../../../../core/services/payment_service.dart';
|
||||
import '../../../../core/services/notification_service.dart';
|
||||
import '../../domain/repositories/cotisation_repository.dart';
|
||||
import 'cotisations_event.dart';
|
||||
import 'cotisations_state.dart';
|
||||
|
||||
/// BLoC pour la gestion des cotisations
|
||||
/// Gère l'état et les événements liés aux cotisations
|
||||
@injectable
|
||||
class CotisationsBloc extends Bloc<CotisationsEvent, CotisationsState> {
|
||||
final CotisationRepository _cotisationRepository;
|
||||
final PaymentService _paymentService;
|
||||
final NotificationService _notificationService;
|
||||
|
||||
CotisationsBloc(
|
||||
this._cotisationRepository,
|
||||
this._paymentService,
|
||||
this._notificationService,
|
||||
) : super(const CotisationsInitial()) {
|
||||
// Enregistrement des handlers d'événements
|
||||
on<LoadCotisations>(_onLoadCotisations);
|
||||
on<LoadCotisationById>(_onLoadCotisationById);
|
||||
on<LoadCotisationByReference>(_onLoadCotisationByReference);
|
||||
on<CreateCotisation>(_onCreateCotisation);
|
||||
on<UpdateCotisation>(_onUpdateCotisation);
|
||||
on<DeleteCotisation>(_onDeleteCotisation);
|
||||
on<LoadCotisationsByMembre>(_onLoadCotisationsByMembre);
|
||||
on<LoadCotisationsByStatut>(_onLoadCotisationsByStatut);
|
||||
on<LoadCotisationsEnRetard>(_onLoadCotisationsEnRetard);
|
||||
on<SearchCotisations>(_onSearchCotisations);
|
||||
on<LoadCotisationsStats>(_onLoadCotisationsStats);
|
||||
on<RefreshCotisations>(_onRefreshCotisations);
|
||||
on<ResetCotisationsState>(_onResetCotisationsState);
|
||||
on<FilterCotisations>(_onFilterCotisations);
|
||||
on<SortCotisations>(_onSortCotisations);
|
||||
|
||||
// Nouveaux handlers pour les paiements et fonctionnalités avancées
|
||||
on<InitiatePayment>(_onInitiatePayment);
|
||||
on<CheckPaymentStatus>(_onCheckPaymentStatus);
|
||||
on<CancelPayment>(_onCancelPayment);
|
||||
on<ScheduleNotifications>(_onScheduleNotifications);
|
||||
on<SyncWithServer>(_onSyncWithServer);
|
||||
on<ApplyAdvancedFilters>(_onApplyAdvancedFilters);
|
||||
on<ExportCotisations>(_onExportCotisations);
|
||||
}
|
||||
|
||||
/// Handler pour charger la liste des cotisations
|
||||
Future<void> _onLoadCotisations(
|
||||
LoadCotisations event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (event.refresh || state is CotisationsInitial) {
|
||||
emit(CotisationsLoading(isRefreshing: event.refresh));
|
||||
}
|
||||
|
||||
final cotisations = await _cotisationRepository.getCotisations(
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
);
|
||||
|
||||
List<CotisationModel> allCotisations = [];
|
||||
|
||||
// Si c'est un refresh ou la première page, remplacer la liste
|
||||
if (event.refresh || event.page == 0) {
|
||||
allCotisations = cotisations;
|
||||
} else {
|
||||
// Sinon, ajouter à la liste existante (pagination)
|
||||
if (state is CotisationsLoaded) {
|
||||
final currentState = state as CotisationsLoaded;
|
||||
allCotisations = [...currentState.cotisations, ...cotisations];
|
||||
} else {
|
||||
allCotisations = cotisations;
|
||||
}
|
||||
}
|
||||
|
||||
emit(CotisationsLoaded(
|
||||
cotisations: allCotisations,
|
||||
filteredCotisations: allCotisations,
|
||||
hasReachedMax: cotisations.length < event.size,
|
||||
currentPage: event.page,
|
||||
));
|
||||
} catch (error) {
|
||||
emit(CotisationsError(
|
||||
'Erreur lors du chargement des cotisations: ${error.toString()}',
|
||||
originalError: error,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour charger une cotisation par ID
|
||||
Future<void> _onLoadCotisationById(
|
||||
LoadCotisationById event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const CotisationsLoading());
|
||||
|
||||
final cotisation = await _cotisationRepository.getCotisationById(event.id);
|
||||
|
||||
emit(CotisationDetailLoaded(cotisation));
|
||||
} catch (error) {
|
||||
emit(CotisationsError(
|
||||
'Erreur lors du chargement de la cotisation: ${error.toString()}',
|
||||
originalError: error,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour charger une cotisation par référence
|
||||
Future<void> _onLoadCotisationByReference(
|
||||
LoadCotisationByReference event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const CotisationsLoading());
|
||||
|
||||
final cotisation = await _cotisationRepository.getCotisationByReference(event.numeroReference);
|
||||
|
||||
emit(CotisationDetailLoaded(cotisation));
|
||||
} catch (error) {
|
||||
emit(CotisationsError(
|
||||
'Erreur lors du chargement de la cotisation: ${error.toString()}',
|
||||
originalError: error,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour créer une nouvelle cotisation
|
||||
Future<void> _onCreateCotisation(
|
||||
CreateCotisation event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const CotisationOperationLoading('create'));
|
||||
|
||||
final nouvelleCotisation = await _cotisationRepository.createCotisation(event.cotisation);
|
||||
|
||||
emit(CotisationCreated(nouvelleCotisation));
|
||||
|
||||
// Recharger la liste des cotisations
|
||||
add(const LoadCotisations(refresh: true));
|
||||
} catch (error) {
|
||||
emit(CotisationsError(
|
||||
'Erreur lors de la création de la cotisation: ${error.toString()}',
|
||||
originalError: error,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour mettre à jour une cotisation
|
||||
Future<void> _onUpdateCotisation(
|
||||
UpdateCotisation event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(CotisationOperationLoading('update', cotisationId: event.id));
|
||||
|
||||
final cotisationMiseAJour = await _cotisationRepository.updateCotisation(
|
||||
event.id,
|
||||
event.cotisation,
|
||||
);
|
||||
|
||||
emit(CotisationUpdated(cotisationMiseAJour));
|
||||
|
||||
// Mettre à jour la liste si elle est chargée
|
||||
if (state is CotisationsLoaded) {
|
||||
final currentState = state as CotisationsLoaded;
|
||||
final updatedList = currentState.cotisations.map((c) {
|
||||
return c.id == event.id ? cotisationMiseAJour : c;
|
||||
}).toList();
|
||||
|
||||
emit(currentState.copyWith(
|
||||
cotisations: updatedList,
|
||||
filteredCotisations: updatedList,
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
emit(CotisationsError(
|
||||
'Erreur lors de la mise à jour de la cotisation: ${error.toString()}',
|
||||
originalError: error,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour supprimer une cotisation
|
||||
Future<void> _onDeleteCotisation(
|
||||
DeleteCotisation event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(CotisationOperationLoading('delete', cotisationId: event.id));
|
||||
|
||||
await _cotisationRepository.deleteCotisation(event.id);
|
||||
|
||||
emit(CotisationDeleted(event.id));
|
||||
|
||||
// Retirer de la liste si elle est chargée
|
||||
if (state is CotisationsLoaded) {
|
||||
final currentState = state as CotisationsLoaded;
|
||||
final updatedList = currentState.cotisations
|
||||
.where((c) => c.id != event.id)
|
||||
.toList();
|
||||
|
||||
emit(currentState.copyWith(
|
||||
cotisations: updatedList,
|
||||
filteredCotisations: updatedList,
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
emit(CotisationsError(
|
||||
'Erreur lors de la suppression de la cotisation: ${error.toString()}',
|
||||
originalError: error,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour charger les cotisations d'un membre
|
||||
Future<void> _onLoadCotisationsByMembre(
|
||||
LoadCotisationsByMembre event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (event.refresh || event.page == 0) {
|
||||
emit(CotisationsLoading(isRefreshing: event.refresh));
|
||||
}
|
||||
|
||||
final cotisations = await _cotisationRepository.getCotisationsByMembre(
|
||||
event.membreId,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
);
|
||||
|
||||
List<CotisationModel> allCotisations = [];
|
||||
|
||||
if (event.refresh || event.page == 0) {
|
||||
allCotisations = cotisations;
|
||||
} else {
|
||||
if (state is CotisationsByMembreLoaded) {
|
||||
final currentState = state as CotisationsByMembreLoaded;
|
||||
allCotisations = [...currentState.cotisations, ...cotisations];
|
||||
} else {
|
||||
allCotisations = cotisations;
|
||||
}
|
||||
}
|
||||
|
||||
emit(CotisationsByMembreLoaded(
|
||||
membreId: event.membreId,
|
||||
cotisations: allCotisations,
|
||||
hasReachedMax: cotisations.length < event.size,
|
||||
currentPage: event.page,
|
||||
));
|
||||
} catch (error) {
|
||||
emit(CotisationsError(
|
||||
'Erreur lors du chargement des cotisations du membre: ${error.toString()}',
|
||||
originalError: error,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour charger les cotisations par statut
|
||||
Future<void> _onLoadCotisationsByStatut(
|
||||
LoadCotisationsByStatut event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (event.refresh || event.page == 0) {
|
||||
emit(CotisationsLoading(isRefreshing: event.refresh));
|
||||
}
|
||||
|
||||
final cotisations = await _cotisationRepository.getCotisationsByStatut(
|
||||
event.statut,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
);
|
||||
|
||||
List<CotisationModel> allCotisations = [];
|
||||
|
||||
if (event.refresh || event.page == 0) {
|
||||
allCotisations = cotisations;
|
||||
} else {
|
||||
if (state is CotisationsLoaded) {
|
||||
final currentState = state as CotisationsLoaded;
|
||||
allCotisations = [...currentState.cotisations, ...cotisations];
|
||||
} else {
|
||||
allCotisations = cotisations;
|
||||
}
|
||||
}
|
||||
|
||||
emit(CotisationsLoaded(
|
||||
cotisations: allCotisations,
|
||||
filteredCotisations: allCotisations,
|
||||
hasReachedMax: cotisations.length < event.size,
|
||||
currentPage: event.page,
|
||||
currentFilter: event.statut,
|
||||
));
|
||||
} catch (error) {
|
||||
emit(CotisationsError(
|
||||
'Erreur lors du chargement des cotisations par statut: ${error.toString()}',
|
||||
originalError: error,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour charger les cotisations en retard
|
||||
Future<void> _onLoadCotisationsEnRetard(
|
||||
LoadCotisationsEnRetard event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (event.refresh || event.page == 0) {
|
||||
emit(CotisationsLoading(isRefreshing: event.refresh));
|
||||
}
|
||||
|
||||
final cotisations = await _cotisationRepository.getCotisationsEnRetard(
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
);
|
||||
|
||||
List<CotisationModel> allCotisations = [];
|
||||
|
||||
if (event.refresh || event.page == 0) {
|
||||
allCotisations = cotisations;
|
||||
} else {
|
||||
if (state is CotisationsEnRetardLoaded) {
|
||||
final currentState = state as CotisationsEnRetardLoaded;
|
||||
allCotisations = [...currentState.cotisations, ...cotisations];
|
||||
} else {
|
||||
allCotisations = cotisations;
|
||||
}
|
||||
}
|
||||
|
||||
emit(CotisationsEnRetardLoaded(
|
||||
cotisations: allCotisations,
|
||||
hasReachedMax: cotisations.length < event.size,
|
||||
currentPage: event.page,
|
||||
));
|
||||
} catch (error) {
|
||||
emit(CotisationsError(
|
||||
'Erreur lors du chargement des cotisations en retard: ${error.toString()}',
|
||||
originalError: error,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour la recherche de cotisations
|
||||
Future<void> _onSearchCotisations(
|
||||
SearchCotisations event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (event.refresh || event.page == 0) {
|
||||
emit(CotisationsLoading(isRefreshing: event.refresh));
|
||||
}
|
||||
|
||||
final cotisations = await _cotisationRepository.rechercherCotisations(
|
||||
membreId: event.membreId,
|
||||
statut: event.statut,
|
||||
typeCotisation: event.typeCotisation,
|
||||
annee: event.annee,
|
||||
mois: event.mois,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
);
|
||||
|
||||
final searchCriteria = <String, dynamic>{
|
||||
if (event.membreId != null) 'membreId': event.membreId,
|
||||
if (event.statut != null) 'statut': event.statut,
|
||||
if (event.typeCotisation != null) 'typeCotisation': event.typeCotisation,
|
||||
if (event.annee != null) 'annee': event.annee,
|
||||
if (event.mois != null) 'mois': event.mois,
|
||||
};
|
||||
|
||||
List<CotisationModel> allCotisations = [];
|
||||
|
||||
if (event.refresh || event.page == 0) {
|
||||
allCotisations = cotisations;
|
||||
} else {
|
||||
if (state is CotisationsSearchResults) {
|
||||
final currentState = state as CotisationsSearchResults;
|
||||
allCotisations = [...currentState.cotisations, ...cotisations];
|
||||
} else {
|
||||
allCotisations = cotisations;
|
||||
}
|
||||
}
|
||||
|
||||
emit(CotisationsSearchResults(
|
||||
cotisations: allCotisations,
|
||||
searchCriteria: searchCriteria,
|
||||
hasReachedMax: cotisations.length < event.size,
|
||||
currentPage: event.page,
|
||||
));
|
||||
} catch (error) {
|
||||
emit(CotisationsError(
|
||||
'Erreur lors de la recherche de cotisations: ${error.toString()}',
|
||||
originalError: error,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour charger les statistiques
|
||||
Future<void> _onLoadCotisationsStats(
|
||||
LoadCotisationsStats event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const CotisationsLoading());
|
||||
|
||||
final statistics = await _cotisationRepository.getCotisationsStats();
|
||||
|
||||
emit(CotisationsStatsLoaded(statistics));
|
||||
} catch (error) {
|
||||
emit(CotisationsError(
|
||||
'Erreur lors du chargement des statistiques: ${error.toString()}',
|
||||
originalError: error,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour rafraîchir les données
|
||||
Future<void> _onRefreshCotisations(
|
||||
RefreshCotisations event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
add(const LoadCotisations(refresh: true));
|
||||
}
|
||||
|
||||
/// Handler pour réinitialiser l'état
|
||||
Future<void> _onResetCotisationsState(
|
||||
ResetCotisationsState event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
emit(const CotisationsInitial());
|
||||
}
|
||||
|
||||
/// Handler pour filtrer les cotisations localement
|
||||
Future<void> _onFilterCotisations(
|
||||
FilterCotisations event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
if (state is CotisationsLoaded) {
|
||||
final currentState = state as CotisationsLoaded;
|
||||
|
||||
List<CotisationModel> filteredList = currentState.cotisations;
|
||||
|
||||
// Filtrage par recherche textuelle
|
||||
if (event.searchQuery != null && event.searchQuery!.isNotEmpty) {
|
||||
final query = event.searchQuery!.toLowerCase();
|
||||
filteredList = filteredList.where((cotisation) {
|
||||
return cotisation.numeroReference.toLowerCase().contains(query) ||
|
||||
(cotisation.nomMembre?.toLowerCase().contains(query) ?? false) ||
|
||||
cotisation.typeCotisation.toLowerCase().contains(query) ||
|
||||
(cotisation.description?.toLowerCase().contains(query) ?? false);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Filtrage par statut
|
||||
if (event.statutFilter != null && event.statutFilter!.isNotEmpty) {
|
||||
filteredList = filteredList.where((cotisation) {
|
||||
return cotisation.statut == event.statutFilter;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Filtrage par type
|
||||
if (event.typeFilter != null && event.typeFilter!.isNotEmpty) {
|
||||
filteredList = filteredList.where((cotisation) {
|
||||
return cotisation.typeCotisation == event.typeFilter;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
emit(currentState.copyWith(
|
||||
filteredCotisations: filteredList,
|
||||
searchQuery: event.searchQuery,
|
||||
currentFilter: event.statutFilter ?? event.typeFilter,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour trier les cotisations
|
||||
Future<void> _onSortCotisations(
|
||||
SortCotisations event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
if (state is CotisationsLoaded) {
|
||||
final currentState = state as CotisationsLoaded;
|
||||
|
||||
List<CotisationModel> sortedList = [...currentState.filteredCotisations];
|
||||
|
||||
switch (event.sortBy) {
|
||||
case 'dateEcheance':
|
||||
sortedList.sort((a, b) => event.ascending
|
||||
? a.dateEcheance.compareTo(b.dateEcheance)
|
||||
: b.dateEcheance.compareTo(a.dateEcheance));
|
||||
break;
|
||||
case 'montantDu':
|
||||
sortedList.sort((a, b) => event.ascending
|
||||
? a.montantDu.compareTo(b.montantDu)
|
||||
: b.montantDu.compareTo(a.montantDu));
|
||||
break;
|
||||
case 'statut':
|
||||
sortedList.sort((a, b) => event.ascending
|
||||
? a.statut.compareTo(b.statut)
|
||||
: b.statut.compareTo(a.statut));
|
||||
break;
|
||||
case 'nomMembre':
|
||||
sortedList.sort((a, b) => event.ascending
|
||||
? (a.nomMembre ?? '').compareTo(b.nomMembre ?? '')
|
||||
: (b.nomMembre ?? '').compareTo(a.nomMembre ?? ''));
|
||||
break;
|
||||
case 'typeCotisation':
|
||||
sortedList.sort((a, b) => event.ascending
|
||||
? a.typeCotisation.compareTo(b.typeCotisation)
|
||||
: b.typeCotisation.compareTo(a.typeCotisation));
|
||||
break;
|
||||
default:
|
||||
// Tri par défaut par date d'échéance
|
||||
sortedList.sort((a, b) => b.dateEcheance.compareTo(a.dateEcheance));
|
||||
}
|
||||
|
||||
emit(currentState.copyWith(filteredCotisations: sortedList));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour initier un paiement
|
||||
Future<void> _onInitiatePayment(
|
||||
InitiatePayment event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
// Valider les données de paiement
|
||||
if (!_paymentService.validatePaymentData(
|
||||
cotisationId: event.cotisationId,
|
||||
montant: event.montant,
|
||||
methodePaiement: event.methodePaiement,
|
||||
numeroTelephone: event.numeroTelephone,
|
||||
)) {
|
||||
emit(PaymentFailure(
|
||||
cotisationId: event.cotisationId,
|
||||
paymentId: '',
|
||||
errorMessage: 'Données de paiement invalides',
|
||||
errorCode: 'INVALID_DATA',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Initier le paiement
|
||||
final payment = await _paymentService.initiatePayment(
|
||||
cotisationId: event.cotisationId,
|
||||
montant: event.montant,
|
||||
methodePaiement: event.methodePaiement,
|
||||
numeroTelephone: event.numeroTelephone,
|
||||
nomPayeur: event.nomPayeur,
|
||||
emailPayeur: event.emailPayeur,
|
||||
);
|
||||
|
||||
emit(PaymentInProgress(
|
||||
cotisationId: event.cotisationId,
|
||||
paymentId: payment.id,
|
||||
methodePaiement: event.methodePaiement,
|
||||
montant: event.montant,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
emit(PaymentFailure(
|
||||
cotisationId: event.cotisationId,
|
||||
paymentId: '',
|
||||
errorMessage: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour vérifier le statut d'un paiement
|
||||
Future<void> _onCheckPaymentStatus(
|
||||
CheckPaymentStatus event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
final payment = await _paymentService.checkPaymentStatus(event.paymentId);
|
||||
|
||||
if (payment.isSuccessful) {
|
||||
// Récupérer la cotisation mise à jour
|
||||
final cotisation = await _cotisationRepository.getCotisationById(payment.cotisationId);
|
||||
|
||||
emit(PaymentSuccess(
|
||||
cotisationId: payment.cotisationId,
|
||||
payment: payment,
|
||||
updatedCotisation: cotisation,
|
||||
));
|
||||
|
||||
// Envoyer notification de succès
|
||||
await _notificationService.showPaymentConfirmation(cotisation, payment.montant);
|
||||
|
||||
} else if (payment.isFailed) {
|
||||
emit(PaymentFailure(
|
||||
cotisationId: payment.cotisationId,
|
||||
paymentId: payment.id,
|
||||
errorMessage: payment.messageErreur ?? 'Paiement échoué',
|
||||
));
|
||||
|
||||
// Envoyer notification d'échec
|
||||
final cotisation = await _cotisationRepository.getCotisationById(payment.cotisationId);
|
||||
await _notificationService.showPaymentFailure(cotisation, payment.messageErreur ?? 'Erreur inconnue');
|
||||
}
|
||||
} catch (e) {
|
||||
emit(CotisationsError('Erreur lors de la vérification du paiement: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour annuler un paiement
|
||||
Future<void> _onCancelPayment(
|
||||
CancelPayment event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
final cancelled = await _paymentService.cancelPayment(event.paymentId);
|
||||
|
||||
if (cancelled) {
|
||||
emit(PaymentCancelled(
|
||||
cotisationId: event.cotisationId,
|
||||
paymentId: event.paymentId,
|
||||
));
|
||||
} else {
|
||||
emit(const CotisationsError('Impossible d\'annuler le paiement'));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(CotisationsError('Erreur lors de l\'annulation du paiement: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour programmer les notifications
|
||||
Future<void> _onScheduleNotifications(
|
||||
ScheduleNotifications event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _notificationService.scheduleAllCotisationsNotifications(event.cotisations);
|
||||
|
||||
emit(NotificationsScheduled(
|
||||
notificationsCount: event.cotisations.length * 2,
|
||||
cotisationIds: event.cotisations.map((c) => c.id).toList(),
|
||||
));
|
||||
} catch (e) {
|
||||
emit(CotisationsError('Erreur lors de la programmation des notifications: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour synchroniser avec le serveur
|
||||
Future<void> _onSyncWithServer(
|
||||
SyncWithServer event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const SyncInProgress('Synchronisation en cours...'));
|
||||
|
||||
// Recharger les données
|
||||
final cotisations = await _cotisationRepository.getCotisations();
|
||||
|
||||
emit(SyncCompleted(
|
||||
itemsSynced: cotisations.length,
|
||||
syncTime: DateTime.now(),
|
||||
));
|
||||
|
||||
// Émettre l'état chargé avec les nouvelles données
|
||||
emit(CotisationsLoaded(
|
||||
cotisations: cotisations,
|
||||
filteredCotisations: cotisations,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
emit(CotisationsError('Erreur lors de la synchronisation: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour appliquer des filtres avancés
|
||||
Future<void> _onApplyAdvancedFilters(
|
||||
ApplyAdvancedFilters event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const CotisationsLoading());
|
||||
|
||||
final cotisations = await _cotisationRepository.rechercherCotisations(
|
||||
membreId: event.filters['membreId'],
|
||||
statut: event.filters['statut'],
|
||||
typeCotisation: event.filters['typeCotisation'],
|
||||
annee: event.filters['annee'],
|
||||
mois: event.filters['mois'],
|
||||
);
|
||||
|
||||
emit(CotisationsSearchResults(
|
||||
cotisations: cotisations,
|
||||
searchCriteria: event.filters,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
emit(CotisationsError('Erreur lors de l\'application des filtres: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour exporter les cotisations
|
||||
Future<void> _onExportCotisations(
|
||||
ExportCotisations event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
final cotisations = event.cotisations ?? [];
|
||||
|
||||
emit(ExportInProgress(
|
||||
format: event.format,
|
||||
totalItems: cotisations.length,
|
||||
));
|
||||
|
||||
// TODO: Implémenter l'export réel selon le format
|
||||
await Future.delayed(const Duration(seconds: 2)); // Simulation
|
||||
|
||||
emit(ExportCompleted(
|
||||
format: event.format,
|
||||
filePath: '/storage/emulated/0/Download/cotisations.${event.format}',
|
||||
itemsExported: cotisations.length,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
emit(CotisationsError('Erreur lors de l\'export: ${e.toString()}'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
|
||||
/// Événements du BLoC des cotisations
|
||||
abstract class CotisationsEvent extends Equatable {
|
||||
const CotisationsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Événement pour charger la liste des cotisations
|
||||
class LoadCotisations extends CotisationsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
final bool refresh;
|
||||
|
||||
const LoadCotisations({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
this.refresh = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size, refresh];
|
||||
}
|
||||
|
||||
/// Événement pour charger une cotisation par ID
|
||||
class LoadCotisationById extends CotisationsEvent {
|
||||
final String id;
|
||||
|
||||
const LoadCotisationById(this.id);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Événement pour charger une cotisation par référence
|
||||
class LoadCotisationByReference extends CotisationsEvent {
|
||||
final String numeroReference;
|
||||
|
||||
const LoadCotisationByReference(this.numeroReference);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [numeroReference];
|
||||
}
|
||||
|
||||
/// Événement pour créer une nouvelle cotisation
|
||||
class CreateCotisation extends CotisationsEvent {
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const CreateCotisation(this.cotisation);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisation];
|
||||
}
|
||||
|
||||
/// Événement pour mettre à jour une cotisation
|
||||
class UpdateCotisation extends CotisationsEvent {
|
||||
final String id;
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const UpdateCotisation(this.id, this.cotisation);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, cotisation];
|
||||
}
|
||||
|
||||
/// Événement pour supprimer une cotisation
|
||||
class DeleteCotisation extends CotisationsEvent {
|
||||
final String id;
|
||||
|
||||
const DeleteCotisation(this.id);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Événement pour charger les cotisations d'un membre
|
||||
class LoadCotisationsByMembre extends CotisationsEvent {
|
||||
final String membreId;
|
||||
final int page;
|
||||
final int size;
|
||||
final bool refresh;
|
||||
|
||||
const LoadCotisationsByMembre(
|
||||
this.membreId, {
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
this.refresh = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membreId, page, size, refresh];
|
||||
}
|
||||
|
||||
/// Événement pour charger les cotisations par statut
|
||||
class LoadCotisationsByStatut extends CotisationsEvent {
|
||||
final String statut;
|
||||
final int page;
|
||||
final int size;
|
||||
final bool refresh;
|
||||
|
||||
const LoadCotisationsByStatut(
|
||||
this.statut, {
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
this.refresh = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [statut, page, size, refresh];
|
||||
}
|
||||
|
||||
/// Événement pour charger les cotisations en retard
|
||||
class LoadCotisationsEnRetard extends CotisationsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
final bool refresh;
|
||||
|
||||
const LoadCotisationsEnRetard({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
this.refresh = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size, refresh];
|
||||
}
|
||||
|
||||
/// Événement pour rechercher des cotisations
|
||||
class SearchCotisations extends CotisationsEvent {
|
||||
final String? membreId;
|
||||
final String? statut;
|
||||
final String? typeCotisation;
|
||||
final int? annee;
|
||||
final int? mois;
|
||||
final int page;
|
||||
final int size;
|
||||
final bool refresh;
|
||||
|
||||
const SearchCotisations({
|
||||
this.membreId,
|
||||
this.statut,
|
||||
this.typeCotisation,
|
||||
this.annee,
|
||||
this.mois,
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
this.refresh = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
membreId,
|
||||
statut,
|
||||
typeCotisation,
|
||||
annee,
|
||||
mois,
|
||||
page,
|
||||
size,
|
||||
refresh,
|
||||
];
|
||||
}
|
||||
|
||||
/// Événement pour charger les statistiques
|
||||
class LoadCotisationsStats extends CotisationsEvent {
|
||||
const LoadCotisationsStats();
|
||||
}
|
||||
|
||||
/// Événement pour rafraîchir les données
|
||||
class RefreshCotisations extends CotisationsEvent {
|
||||
const RefreshCotisations();
|
||||
}
|
||||
|
||||
/// Événement pour réinitialiser l'état
|
||||
class ResetCotisationsState extends CotisationsEvent {
|
||||
const ResetCotisationsState();
|
||||
}
|
||||
|
||||
/// Événement pour filtrer les cotisations localement
|
||||
class FilterCotisations extends CotisationsEvent {
|
||||
final String? searchQuery;
|
||||
final String? statutFilter;
|
||||
final String? typeFilter;
|
||||
|
||||
const FilterCotisations({
|
||||
this.searchQuery,
|
||||
this.statutFilter,
|
||||
this.typeFilter,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [searchQuery, statutFilter, typeFilter];
|
||||
}
|
||||
|
||||
/// Événement pour trier les cotisations
|
||||
class SortCotisations extends CotisationsEvent {
|
||||
final String sortBy; // 'dateEcheance', 'montantDu', 'statut', etc.
|
||||
final bool ascending;
|
||||
|
||||
const SortCotisations(this.sortBy, {this.ascending = true});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [sortBy, ascending];
|
||||
}
|
||||
|
||||
/// Événement pour initier un paiement
|
||||
class InitiatePayment extends CotisationsEvent {
|
||||
final String cotisationId;
|
||||
final double montant;
|
||||
final String methodePaiement;
|
||||
final String numeroTelephone;
|
||||
final String? nomPayeur;
|
||||
final String? emailPayeur;
|
||||
|
||||
const InitiatePayment({
|
||||
required this.cotisationId,
|
||||
required this.montant,
|
||||
required this.methodePaiement,
|
||||
required this.numeroTelephone,
|
||||
this.nomPayeur,
|
||||
this.emailPayeur,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
cotisationId,
|
||||
montant,
|
||||
methodePaiement,
|
||||
numeroTelephone,
|
||||
nomPayeur,
|
||||
emailPayeur,
|
||||
];
|
||||
}
|
||||
|
||||
/// Événement pour vérifier le statut d'un paiement
|
||||
class CheckPaymentStatus extends CotisationsEvent {
|
||||
final String paymentId;
|
||||
|
||||
const CheckPaymentStatus(this.paymentId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [paymentId];
|
||||
}
|
||||
|
||||
/// Événement pour annuler un paiement
|
||||
class CancelPayment extends CotisationsEvent {
|
||||
final String paymentId;
|
||||
final String cotisationId;
|
||||
|
||||
const CancelPayment({
|
||||
required this.paymentId,
|
||||
required this.cotisationId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [paymentId, cotisationId];
|
||||
}
|
||||
|
||||
/// Événement pour programmer des notifications
|
||||
class ScheduleNotifications extends CotisationsEvent {
|
||||
final List<CotisationModel> cotisations;
|
||||
|
||||
const ScheduleNotifications(this.cotisations);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisations];
|
||||
}
|
||||
|
||||
/// Événement pour synchroniser avec le serveur
|
||||
class SyncWithServer extends CotisationsEvent {
|
||||
final bool forceSync;
|
||||
|
||||
const SyncWithServer({this.forceSync = false});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [forceSync];
|
||||
}
|
||||
|
||||
/// Événement pour appliquer des filtres avancés
|
||||
class ApplyAdvancedFilters extends CotisationsEvent {
|
||||
final Map<String, dynamic> filters;
|
||||
|
||||
const ApplyAdvancedFilters(this.filters);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [filters];
|
||||
}
|
||||
|
||||
/// Événement pour exporter des données
|
||||
class ExportCotisations extends CotisationsEvent {
|
||||
final String format; // 'pdf', 'excel', 'csv'
|
||||
final List<CotisationModel>? cotisations;
|
||||
|
||||
const ExportCotisations(this.format, {this.cotisations});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [format, cotisations];
|
||||
}
|
||||
|
||||
/// Événement pour charger l'historique des paiements
|
||||
class LoadPaymentHistory extends CotisationsEvent {
|
||||
final String? membreId;
|
||||
final String? period;
|
||||
final String? status;
|
||||
final String? method;
|
||||
final String? searchQuery;
|
||||
|
||||
const LoadPaymentHistory({
|
||||
this.membreId,
|
||||
this.period,
|
||||
this.status,
|
||||
this.method,
|
||||
this.searchQuery,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membreId, period, status, method, searchQuery];
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../core/models/payment_model.dart';
|
||||
|
||||
/// États du BLoC des cotisations
|
||||
abstract class CotisationsState extends Equatable {
|
||||
const CotisationsState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
class CotisationsInitial extends CotisationsState {
|
||||
const CotisationsInitial();
|
||||
}
|
||||
|
||||
/// État de chargement
|
||||
class CotisationsLoading extends CotisationsState {
|
||||
final bool isRefreshing;
|
||||
|
||||
const CotisationsLoading({this.isRefreshing = false});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [isRefreshing];
|
||||
}
|
||||
|
||||
/// État de succès avec liste des cotisations
|
||||
class CotisationsLoaded extends CotisationsState {
|
||||
final List<CotisationModel> cotisations;
|
||||
final List<CotisationModel> filteredCotisations;
|
||||
final Map<String, dynamic>? statistics;
|
||||
final bool hasReachedMax;
|
||||
final int currentPage;
|
||||
final String? currentFilter;
|
||||
final String? searchQuery;
|
||||
|
||||
const CotisationsLoaded({
|
||||
required this.cotisations,
|
||||
required this.filteredCotisations,
|
||||
this.statistics,
|
||||
this.hasReachedMax = false,
|
||||
this.currentPage = 0,
|
||||
this.currentFilter,
|
||||
this.searchQuery,
|
||||
});
|
||||
|
||||
/// Copie avec modifications
|
||||
CotisationsLoaded copyWith({
|
||||
List<CotisationModel>? cotisations,
|
||||
List<CotisationModel>? filteredCotisations,
|
||||
Map<String, dynamic>? statistics,
|
||||
bool? hasReachedMax,
|
||||
int? currentPage,
|
||||
String? currentFilter,
|
||||
String? searchQuery,
|
||||
}) {
|
||||
return CotisationsLoaded(
|
||||
cotisations: cotisations ?? this.cotisations,
|
||||
filteredCotisations: filteredCotisations ?? this.filteredCotisations,
|
||||
statistics: statistics ?? this.statistics,
|
||||
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
currentFilter: currentFilter ?? this.currentFilter,
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
cotisations,
|
||||
filteredCotisations,
|
||||
statistics,
|
||||
hasReachedMax,
|
||||
currentPage,
|
||||
currentFilter,
|
||||
searchQuery,
|
||||
];
|
||||
}
|
||||
|
||||
/// État de succès pour une cotisation unique
|
||||
class CotisationDetailLoaded extends CotisationsState {
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const CotisationDetailLoaded(this.cotisation);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisation];
|
||||
}
|
||||
|
||||
/// État de succès pour la création d'une cotisation
|
||||
class CotisationCreated extends CotisationsState {
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const CotisationCreated(this.cotisation);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisation];
|
||||
}
|
||||
|
||||
/// État de succès pour la mise à jour d'une cotisation
|
||||
class CotisationUpdated extends CotisationsState {
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const CotisationUpdated(this.cotisation);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisation];
|
||||
}
|
||||
|
||||
/// État de succès pour la suppression d'une cotisation
|
||||
class CotisationDeleted extends CotisationsState {
|
||||
final String cotisationId;
|
||||
|
||||
const CotisationDeleted(this.cotisationId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisationId];
|
||||
}
|
||||
|
||||
/// État d'erreur
|
||||
class CotisationsError extends CotisationsState {
|
||||
final String message;
|
||||
final String? errorCode;
|
||||
final dynamic originalError;
|
||||
|
||||
const CotisationsError(
|
||||
this.message, {
|
||||
this.errorCode,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, errorCode, originalError];
|
||||
}
|
||||
|
||||
/// État de chargement pour une opération spécifique
|
||||
class CotisationOperationLoading extends CotisationsState {
|
||||
final String operation; // 'create', 'update', 'delete'
|
||||
final String? cotisationId;
|
||||
|
||||
const CotisationOperationLoading(this.operation, {this.cotisationId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [operation, cotisationId];
|
||||
}
|
||||
|
||||
/// État de succès pour les statistiques
|
||||
class CotisationsStatsLoaded extends CotisationsState {
|
||||
final Map<String, dynamic> statistics;
|
||||
|
||||
const CotisationsStatsLoaded(this.statistics);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [statistics];
|
||||
}
|
||||
|
||||
/// État pour les cotisations filtrées par membre
|
||||
class CotisationsByMembreLoaded extends CotisationsState {
|
||||
final String membreId;
|
||||
final List<CotisationModel> cotisations;
|
||||
final bool hasReachedMax;
|
||||
final int currentPage;
|
||||
|
||||
const CotisationsByMembreLoaded({
|
||||
required this.membreId,
|
||||
required this.cotisations,
|
||||
this.hasReachedMax = false,
|
||||
this.currentPage = 0,
|
||||
});
|
||||
|
||||
CotisationsByMembreLoaded copyWith({
|
||||
String? membreId,
|
||||
List<CotisationModel>? cotisations,
|
||||
bool? hasReachedMax,
|
||||
int? currentPage,
|
||||
}) {
|
||||
return CotisationsByMembreLoaded(
|
||||
membreId: membreId ?? this.membreId,
|
||||
cotisations: cotisations ?? this.cotisations,
|
||||
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membreId, cotisations, hasReachedMax, currentPage];
|
||||
}
|
||||
|
||||
/// État pour les cotisations en retard
|
||||
class CotisationsEnRetardLoaded extends CotisationsState {
|
||||
final List<CotisationModel> cotisations;
|
||||
final bool hasReachedMax;
|
||||
final int currentPage;
|
||||
|
||||
const CotisationsEnRetardLoaded({
|
||||
required this.cotisations,
|
||||
this.hasReachedMax = false,
|
||||
this.currentPage = 0,
|
||||
});
|
||||
|
||||
CotisationsEnRetardLoaded copyWith({
|
||||
List<CotisationModel>? cotisations,
|
||||
bool? hasReachedMax,
|
||||
int? currentPage,
|
||||
}) {
|
||||
return CotisationsEnRetardLoaded(
|
||||
cotisations: cotisations ?? this.cotisations,
|
||||
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisations, hasReachedMax, currentPage];
|
||||
}
|
||||
|
||||
/// État pour les résultats de recherche
|
||||
class CotisationsSearchResults extends CotisationsState {
|
||||
final List<CotisationModel> cotisations;
|
||||
final Map<String, dynamic> searchCriteria;
|
||||
final bool hasReachedMax;
|
||||
final int currentPage;
|
||||
|
||||
const CotisationsSearchResults({
|
||||
required this.cotisations,
|
||||
required this.searchCriteria,
|
||||
this.hasReachedMax = false,
|
||||
this.currentPage = 0,
|
||||
});
|
||||
|
||||
CotisationsSearchResults copyWith({
|
||||
List<CotisationModel>? cotisations,
|
||||
Map<String, dynamic>? searchCriteria,
|
||||
bool? hasReachedMax,
|
||||
int? currentPage,
|
||||
}) {
|
||||
return CotisationsSearchResults(
|
||||
cotisations: cotisations ?? this.cotisations,
|
||||
searchCriteria: searchCriteria ?? this.searchCriteria,
|
||||
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisations, searchCriteria, hasReachedMax, currentPage];
|
||||
}
|
||||
|
||||
/// État pour un paiement en cours
|
||||
class PaymentInProgress extends CotisationsState {
|
||||
final String cotisationId;
|
||||
final String paymentId;
|
||||
final String methodePaiement;
|
||||
final double montant;
|
||||
|
||||
const PaymentInProgress({
|
||||
required this.cotisationId,
|
||||
required this.paymentId,
|
||||
required this.methodePaiement,
|
||||
required this.montant,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisationId, paymentId, methodePaiement, montant];
|
||||
}
|
||||
|
||||
/// État pour un paiement réussi
|
||||
class PaymentSuccess extends CotisationsState {
|
||||
final String cotisationId;
|
||||
final PaymentModel payment;
|
||||
final CotisationModel updatedCotisation;
|
||||
|
||||
const PaymentSuccess({
|
||||
required this.cotisationId,
|
||||
required this.payment,
|
||||
required this.updatedCotisation,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisationId, payment, updatedCotisation];
|
||||
}
|
||||
|
||||
/// État pour un paiement échoué
|
||||
class PaymentFailure extends CotisationsState {
|
||||
final String cotisationId;
|
||||
final String paymentId;
|
||||
final String errorMessage;
|
||||
final String? errorCode;
|
||||
|
||||
const PaymentFailure({
|
||||
required this.cotisationId,
|
||||
required this.paymentId,
|
||||
required this.errorMessage,
|
||||
this.errorCode,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisationId, paymentId, errorMessage, errorCode];
|
||||
}
|
||||
|
||||
/// État pour un paiement annulé
|
||||
class PaymentCancelled extends CotisationsState {
|
||||
final String cotisationId;
|
||||
final String paymentId;
|
||||
|
||||
const PaymentCancelled({
|
||||
required this.cotisationId,
|
||||
required this.paymentId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisationId, paymentId];
|
||||
}
|
||||
|
||||
/// État pour la synchronisation en cours
|
||||
class SyncInProgress extends CotisationsState {
|
||||
final String message;
|
||||
|
||||
const SyncInProgress(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État pour la synchronisation terminée
|
||||
class SyncCompleted extends CotisationsState {
|
||||
final int itemsSynced;
|
||||
final DateTime syncTime;
|
||||
|
||||
const SyncCompleted({
|
||||
required this.itemsSynced,
|
||||
required this.syncTime,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [itemsSynced, syncTime];
|
||||
}
|
||||
|
||||
/// État pour l'export en cours
|
||||
class ExportInProgress extends CotisationsState {
|
||||
final String format;
|
||||
final int totalItems;
|
||||
|
||||
const ExportInProgress({
|
||||
required this.format,
|
||||
required this.totalItems,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [format, totalItems];
|
||||
}
|
||||
|
||||
/// État pour l'export terminé
|
||||
class ExportCompleted extends CotisationsState {
|
||||
final String format;
|
||||
final String filePath;
|
||||
final int itemsExported;
|
||||
|
||||
const ExportCompleted({
|
||||
required this.format,
|
||||
required this.filePath,
|
||||
required this.itemsExported,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [format, filePath, itemsExported];
|
||||
}
|
||||
|
||||
/// État pour les notifications programmées
|
||||
class NotificationsScheduled extends CotisationsState {
|
||||
final int notificationsCount;
|
||||
final List<String> cotisationIds;
|
||||
|
||||
const NotificationsScheduled({
|
||||
required this.notificationsCount,
|
||||
required this.cotisationIds,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [notificationsCount, cotisationIds];
|
||||
}
|
||||
|
||||
/// État d'historique des paiements chargé
|
||||
class PaymentHistoryLoaded extends CotisationsState {
|
||||
final List<PaymentModel> payments;
|
||||
|
||||
const PaymentHistoryLoaded(this.payments);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [payments];
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../core/models/membre_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/custom_text_field.dart';
|
||||
import '../../../../shared/widgets/loading_button.dart';
|
||||
|
||||
import '../bloc/cotisations_bloc.dart';
|
||||
import '../bloc/cotisations_event.dart';
|
||||
import '../bloc/cotisations_state.dart';
|
||||
|
||||
/// Page de création d'une nouvelle cotisation
|
||||
class CotisationCreatePage extends StatefulWidget {
|
||||
final MembreModel? membre; // Membre pré-sélectionné (optionnel)
|
||||
|
||||
const CotisationCreatePage({
|
||||
super.key,
|
||||
this.membre,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CotisationCreatePage> createState() => _CotisationCreatePageState();
|
||||
}
|
||||
|
||||
class _CotisationCreatePageState extends State<CotisationCreatePage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late CotisationsBloc _cotisationsBloc;
|
||||
|
||||
// Contrôleurs de champs
|
||||
final _montantController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _periodeController = TextEditingController();
|
||||
|
||||
// Valeurs sélectionnées
|
||||
String _typeCotisation = 'MENSUELLE';
|
||||
DateTime _dateEcheance = DateTime.now().add(const Duration(days: 30));
|
||||
MembreModel? _membreSelectionne;
|
||||
|
||||
// Options disponibles
|
||||
final List<String> _typesCotisation = [
|
||||
'MENSUELLE',
|
||||
'TRIMESTRIELLE',
|
||||
'SEMESTRIELLE',
|
||||
'ANNUELLE',
|
||||
'EXCEPTIONNELLE',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cotisationsBloc = getIt<CotisationsBloc>();
|
||||
_membreSelectionne = widget.membre;
|
||||
|
||||
// Pré-remplir la période selon le type
|
||||
_updatePeriodeFromType();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_montantController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_periodeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updatePeriodeFromType() {
|
||||
final now = DateTime.now();
|
||||
String periode;
|
||||
|
||||
switch (_typeCotisation) {
|
||||
case 'MENSUELLE':
|
||||
periode = '${_getMonthName(now.month)} ${now.year}';
|
||||
break;
|
||||
case 'TRIMESTRIELLE':
|
||||
final trimestre = ((now.month - 1) ~/ 3) + 1;
|
||||
periode = 'T$trimestre ${now.year}';
|
||||
break;
|
||||
case 'SEMESTRIELLE':
|
||||
final semestre = now.month <= 6 ? 1 : 2;
|
||||
periode = 'S$semestre ${now.year}';
|
||||
break;
|
||||
case 'ANNUELLE':
|
||||
periode = '${now.year}';
|
||||
break;
|
||||
case 'EXCEPTIONNELLE':
|
||||
periode = 'Exceptionnelle ${now.day}/${now.month}/${now.year}';
|
||||
break;
|
||||
default:
|
||||
periode = '${now.month}/${now.year}';
|
||||
}
|
||||
|
||||
_periodeController.text = periode;
|
||||
}
|
||||
|
||||
String _getMonthName(int month) {
|
||||
const months = [
|
||||
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
|
||||
];
|
||||
return months[month - 1];
|
||||
}
|
||||
|
||||
void _onTypeChanged(String? newType) {
|
||||
if (newType != null) {
|
||||
setState(() {
|
||||
_typeCotisation = newType;
|
||||
_updatePeriodeFromType();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _dateEcheance,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
locale: const Locale('fr', 'FR'),
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_dateEcheance = picked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectMembre() async {
|
||||
// TODO: Implémenter la sélection de membre
|
||||
// Pour l'instant, afficher un message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Fonctionnalité de sélection de membre à implémenter'),
|
||||
backgroundColor: AppTheme.infoColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _createCotisation() {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_membreSelectionne == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez sélectionner un membre'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final montant = double.tryParse(_montantController.text.replaceAll(' ', ''));
|
||||
if (montant == null || montant <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez saisir un montant valide'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer la cotisation
|
||||
final cotisation = CotisationModel(
|
||||
id: '', // Sera généré par le backend
|
||||
numeroReference: '', // Sera généré par le backend
|
||||
membreId: _membreSelectionne!.id ?? '',
|
||||
nomMembre: _membreSelectionne!.nomComplet,
|
||||
typeCotisation: _typeCotisation,
|
||||
montantDu: montant,
|
||||
montantPaye: 0.0,
|
||||
dateEcheance: _dateEcheance,
|
||||
statut: 'EN_ATTENTE',
|
||||
description: _descriptionController.text.trim(),
|
||||
periode: _periodeController.text.trim(),
|
||||
annee: _dateEcheance.year,
|
||||
mois: _dateEcheance.month,
|
||||
codeDevise: 'XOF',
|
||||
recurrente: _typeCotisation != 'EXCEPTIONNELLE',
|
||||
nombreRappels: 0,
|
||||
dateCreation: DateTime.now(),
|
||||
dateModification: DateTime.now(),
|
||||
);
|
||||
|
||||
_cotisationsBloc.add(CreateCotisation(cotisation));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _cotisationsBloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
appBar: AppBar(
|
||||
title: const Text('Nouvelle Cotisation'),
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: BlocListener<CotisationsBloc, CotisationsState>(
|
||||
listener: (context, state) {
|
||||
if (state is CotisationCreated) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Cotisation créée avec succès'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
} else if (state is CotisationsError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Sélection du membre
|
||||
_buildMembreSection(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Type de cotisation
|
||||
_buildTypeSection(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Montant
|
||||
_buildMontantSection(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Période et échéance
|
||||
_buildPeriodeSection(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Description
|
||||
_buildDescriptionSection(),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Bouton de création
|
||||
_buildCreateButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembreSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Membre',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_membreSelectionne != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.accentColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
child: Text(
|
||||
_membreSelectionne!.nomComplet.substring(0, 1).toUpperCase(),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_membreSelectionne!.nomComplet,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_membreSelectionne!.telephone.isNotEmpty
|
||||
? _membreSelectionne!.telephone
|
||||
: 'Pas de téléphone',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.change_circle),
|
||||
onPressed: _selectMembre,
|
||||
color: AppTheme.accentColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
ElevatedButton.icon(
|
||||
onPressed: _selectMembre,
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: const Text('Sélectionner un membre'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Type de cotisation',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _typeCotisation,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: _typesCotisation.map((type) {
|
||||
return DropdownMenuItem(
|
||||
value: type,
|
||||
child: Text(_getTypeLabel(type)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: _onTypeChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getTypeLabel(String type) {
|
||||
switch (type) {
|
||||
case 'MENSUELLE': return 'Mensuelle';
|
||||
case 'TRIMESTRIELLE': return 'Trimestrielle';
|
||||
case 'SEMESTRIELLE': return 'Semestrielle';
|
||||
case 'ANNUELLE': return 'Annuelle';
|
||||
case 'EXCEPTIONNELLE': return 'Exceptionnelle';
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMontantSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Montant',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomTextField(
|
||||
controller: _montantController,
|
||||
label: 'Montant (XOF)',
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||
// Formater avec des espaces pour les milliers
|
||||
final text = newValue.text.replaceAll(' ', '');
|
||||
if (text.isEmpty) return newValue;
|
||||
|
||||
final number = int.tryParse(text);
|
||||
if (number == null) return oldValue;
|
||||
|
||||
final formatted = number.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]} ',
|
||||
);
|
||||
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez saisir un montant';
|
||||
}
|
||||
final montant = double.tryParse(value.replaceAll(' ', ''));
|
||||
if (montant == null || montant <= 0) {
|
||||
return 'Veuillez saisir un montant valide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
suffixIcon: const Icon(Icons.attach_money),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPeriodeSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Période et échéance',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomTextField(
|
||||
controller: _periodeController,
|
||||
label: 'Période',
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez saisir une période';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InkWell(
|
||||
onTap: _selectDate,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.calendar_today, color: AppTheme.accentColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Date d\'échéance',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_dateEcheance.day}/${_dateEcheance.month}/${_dateEcheance.year}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescriptionSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Description (optionnelle)',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomTextField(
|
||||
controller: _descriptionController,
|
||||
label: 'Description de la cotisation',
|
||||
maxLines: 3,
|
||||
maxLength: 500,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreateButton() {
|
||||
return BlocBuilder<CotisationsBloc, CotisationsState>(
|
||||
builder: (context, state) {
|
||||
final isLoading = state is CotisationsLoading;
|
||||
|
||||
return LoadingButton(
|
||||
onPressed: isLoading ? null : _createCotisation,
|
||||
isLoading: isLoading,
|
||||
text: 'Créer la cotisation',
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
textColor: Colors.white,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,752 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../core/models/payment_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/buttons/buttons.dart';
|
||||
import '../../../../shared/widgets/buttons/primary_button.dart';
|
||||
import '../bloc/cotisations_bloc.dart';
|
||||
import '../bloc/cotisations_event.dart';
|
||||
import '../bloc/cotisations_state.dart';
|
||||
import '../widgets/payment_method_selector.dart';
|
||||
import '../widgets/payment_form_widget.dart';
|
||||
import '../widgets/wave_payment_widget.dart';
|
||||
import '../widgets/cotisation_timeline_widget.dart';
|
||||
|
||||
/// Page de détail d'une cotisation
|
||||
class CotisationDetailPage extends StatefulWidget {
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const CotisationDetailPage({
|
||||
super.key,
|
||||
required this.cotisation,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CotisationDetailPage> createState() => _CotisationDetailPageState();
|
||||
}
|
||||
|
||||
class _CotisationDetailPageState extends State<CotisationDetailPage>
|
||||
with TickerProviderStateMixin {
|
||||
late final CotisationsBloc _cotisationsBloc;
|
||||
late final TabController _tabController;
|
||||
late final AnimationController _animationController;
|
||||
late final Animation<double> _fadeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cotisationsBloc = getIt<CotisationsBloc>();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
vsync: this,
|
||||
);
|
||||
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _cotisationsBloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
body: BlocListener<CotisationsBloc, CotisationsState>(
|
||||
listener: (context, state) {
|
||||
if (state is PaymentSuccess) {
|
||||
_showPaymentSuccessDialog(state);
|
||||
} else if (state is PaymentFailure) {
|
||||
_showPaymentErrorDialog(state);
|
||||
} else if (state is PaymentInProgress) {
|
||||
_showPaymentProgressDialog(state);
|
||||
}
|
||||
},
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildStatusCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildTabSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: _buildBottomActions(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar() {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
pinned: true,
|
||||
backgroundColor: _getStatusColor(),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
title: Text(
|
||||
widget.cotisation.typeCotisation,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
background: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
_getStatusColor(),
|
||||
_getStatusColor().withOpacity(0.8),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -50,
|
||||
top: -50,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
child: Icon(
|
||||
_getStatusIcon(),
|
||||
size: 80,
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share, color: Colors.white),
|
||||
onPressed: _shareReceipt,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||
onSelected: _handleMenuAction,
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'export',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.download),
|
||||
SizedBox(width: 8),
|
||||
Text('Exporter'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'print',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.print),
|
||||
SizedBox(width: 8),
|
||||
Text('Imprimer'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'history',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.history),
|
||||
SizedBox(width: 8),
|
||||
Text('Historique'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusCard() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Montant à payer',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${widget.cotisation.montantDu.toStringAsFixed(0)} XOF',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor().withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getStatusIcon(),
|
||||
size: 16,
|
||||
color: _getStatusColor(),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.cotisation.statut,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _getStatusColor(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildInfoRow('Membre', widget.cotisation.nomMembre ?? 'N/A'),
|
||||
_buildInfoRow('Période', _formatPeriode()),
|
||||
_buildInfoRow('Échéance', _formatDate(widget.cotisation.dateEcheance)),
|
||||
if (widget.cotisation.montantPaye > 0)
|
||||
_buildInfoRow('Montant payé', '${widget.cotisation.montantPaye.toStringAsFixed(0)} XOF'),
|
||||
if (widget.cotisation.isEnRetard)
|
||||
_buildInfoRow('Retard', '${widget.cotisation.joursRetard} jours', isWarning: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value, {bool isWarning = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isWarning ? AppTheme.warningColor : AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabSection() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppTheme.primaryColor,
|
||||
unselectedLabelColor: AppTheme.textSecondary,
|
||||
indicatorColor: AppTheme.primaryColor,
|
||||
tabs: const [
|
||||
Tab(text: 'Détails', icon: Icon(Icons.info_outline)),
|
||||
Tab(text: 'Paiement', icon: Icon(Icons.payment)),
|
||||
Tab(text: 'Historique', icon: Icon(Icons.history)),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
height: 400,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildDetailsTab(),
|
||||
_buildPaymentTab(),
|
||||
_buildHistoryTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailsTab() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDetailSection('Informations générales', [
|
||||
_buildDetailItem('Type', widget.cotisation.typeCotisation),
|
||||
_buildDetailItem('Référence', widget.cotisation.numeroReference),
|
||||
_buildDetailItem('Date création', _formatDate(widget.cotisation.dateCreation)),
|
||||
_buildDetailItem('Statut', widget.cotisation.statut),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_buildDetailSection('Montants', [
|
||||
_buildDetailItem('Montant dû', '${widget.cotisation.montantDu.toStringAsFixed(0)} XOF'),
|
||||
_buildDetailItem('Montant payé', '${widget.cotisation.montantPaye.toStringAsFixed(0)} XOF'),
|
||||
_buildDetailItem('Reste à payer', '${(widget.cotisation.montantDu - widget.cotisation.montantPaye).toStringAsFixed(0)} XOF'),
|
||||
]),
|
||||
if (widget.cotisation.description?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildDetailSection('Description', [
|
||||
Text(
|
||||
widget.cotisation.description!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentTab() {
|
||||
if (widget.cotisation.isEntierementPayee) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 64,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Cotisation entièrement payée',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return BlocBuilder<CotisationsBloc, CotisationsState>(
|
||||
builder: (context, state) {
|
||||
if (state is PaymentInProgress) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Traitement du paiement en cours...'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Widget Wave Money en priorité
|
||||
WavePaymentWidget(
|
||||
cotisation: widget.cotisation,
|
||||
showFullInterface: true,
|
||||
onPaymentInitiated: () {
|
||||
// Feedback visuel lors de l'initiation du paiement
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Redirection vers Wave Money...'),
|
||||
backgroundColor: Color(0xFF00D4FF),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Séparateur avec texte
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Divider()),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: const Text(
|
||||
'Ou choisir une autre méthode',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Formulaire de paiement classique
|
||||
PaymentFormWidget(
|
||||
cotisation: widget.cotisation,
|
||||
onPaymentInitiated: (paymentData) {
|
||||
_cotisationsBloc.add(InitiatePayment(
|
||||
cotisationId: widget.cotisation.id,
|
||||
montant: paymentData['montant'],
|
||||
methodePaiement: paymentData['methodePaiement'],
|
||||
numeroTelephone: paymentData['numeroTelephone'],
|
||||
nomPayeur: paymentData['nomPayeur'],
|
||||
emailPayeur: paymentData['emailPayeur'],
|
||||
));
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryTab() {
|
||||
return CotisationTimelineWidget(cotisation: widget.cotisation);
|
||||
}
|
||||
|
||||
Widget _buildDetailSection(String title, List<Widget> children) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailItem(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomActions() {
|
||||
if (widget.cotisation.isEntierementPayee) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: PrimaryButton(
|
||||
text: 'Télécharger le reçu',
|
||||
icon: Icons.download,
|
||||
onPressed: _downloadReceipt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _scheduleReminder,
|
||||
icon: const Icon(Icons.notifications),
|
||||
label: const Text('Rappel'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: PrimaryButton(
|
||||
text: 'Payer maintenant',
|
||||
icon: Icons.payment,
|
||||
onPressed: () {
|
||||
_tabController.animateTo(1); // Aller à l'onglet paiement
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthodes utilitaires
|
||||
Color _getStatusColor() {
|
||||
switch (widget.cotisation.statut.toLowerCase()) {
|
||||
case 'payee':
|
||||
return AppTheme.successColor;
|
||||
case 'en_retard':
|
||||
return AppTheme.errorColor;
|
||||
case 'en_attente':
|
||||
return AppTheme.warningColor;
|
||||
default:
|
||||
return AppTheme.primaryColor;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getStatusIcon() {
|
||||
switch (widget.cotisation.statut.toLowerCase()) {
|
||||
case 'payee':
|
||||
return Icons.check_circle;
|
||||
case 'en_retard':
|
||||
return Icons.warning;
|
||||
case 'en_attente':
|
||||
return Icons.schedule;
|
||||
default:
|
||||
return Icons.payment;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
|
||||
}
|
||||
|
||||
String _formatPeriode() {
|
||||
return '${widget.cotisation.mois}/${widget.cotisation.annee}';
|
||||
}
|
||||
|
||||
// Actions
|
||||
void _shareReceipt() {
|
||||
// TODO: Implémenter le partage
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Partage - En cours de développement')),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMenuAction(String action) {
|
||||
switch (action) {
|
||||
case 'export':
|
||||
_exportReceipt();
|
||||
break;
|
||||
case 'print':
|
||||
_printReceipt();
|
||||
break;
|
||||
case 'history':
|
||||
_showFullHistory();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _exportReceipt() {
|
||||
_cotisationsBloc.add(ExportCotisations('pdf', cotisations: [widget.cotisation]));
|
||||
}
|
||||
|
||||
void _printReceipt() {
|
||||
// TODO: Implémenter l'impression
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Impression - En cours de développement')),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFullHistory() {
|
||||
// TODO: Naviguer vers l'historique complet
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Historique complet - En cours de développement')),
|
||||
);
|
||||
}
|
||||
|
||||
void _downloadReceipt() {
|
||||
_exportReceipt();
|
||||
}
|
||||
|
||||
void _scheduleReminder() {
|
||||
_cotisationsBloc.add(ScheduleNotifications([widget.cotisation]));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Rappel programmé avec succès'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
void _showPaymentSuccessDialog(PaymentSuccess state) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: AppTheme.successColor),
|
||||
SizedBox(width: 8),
|
||||
Text('Paiement réussi'),
|
||||
],
|
||||
),
|
||||
content: Text('Votre paiement de ${state.payment.montant.toStringAsFixed(0)} XOF a été confirmé.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop(); // Retour à la liste
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPaymentErrorDialog(PaymentFailure state) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.error, color: AppTheme.errorColor),
|
||||
SizedBox(width: 8),
|
||||
Text('Échec du paiement'),
|
||||
],
|
||||
),
|
||||
content: Text(state.errorMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPaymentProgressDialog(PaymentInProgress state) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text('Traitement du paiement de ${state.montant.toStringAsFixed(0)} XOF...'),
|
||||
const SizedBox(height: 8),
|
||||
Text('Méthode: ${state.methodePaiement}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/coming_soon_page.dart';
|
||||
import '../bloc/cotisations_bloc.dart';
|
||||
import '../bloc/cotisations_event.dart';
|
||||
import '../bloc/cotisations_state.dart';
|
||||
import '../widgets/cotisation_card.dart';
|
||||
import '../widgets/cotisations_stats_card.dart';
|
||||
import 'cotisation_detail_page.dart';
|
||||
import 'cotisations_search_page.dart';
|
||||
|
||||
// Import de l'architecture unifiée pour amélioration progressive
|
||||
import '../../../../shared/widgets/common/unified_page_layout.dart';
|
||||
|
||||
/// Page principale pour la liste des cotisations
|
||||
class CotisationsListPage extends StatefulWidget {
|
||||
const CotisationsListPage({super.key});
|
||||
|
||||
@override
|
||||
State<CotisationsListPage> createState() => _CotisationsListPageState();
|
||||
}
|
||||
|
||||
class _CotisationsListPageState extends State<CotisationsListPage> {
|
||||
late final CotisationsBloc _cotisationsBloc;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cotisationsBloc = getIt<CotisationsBloc>();
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
// Charger les données initiales
|
||||
_cotisationsBloc.add(const LoadCotisations());
|
||||
_cotisationsBloc.add(const LoadCotisationsStats());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_cotisationsBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_isBottom) {
|
||||
final currentState = _cotisationsBloc.state;
|
||||
if (currentState is CotisationsLoaded && !currentState.hasReachedMax) {
|
||||
_cotisationsBloc.add(LoadCotisations(
|
||||
page: currentState.currentPage + 1,
|
||||
size: 20,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isBottom {
|
||||
if (!_scrollController.hasClients) return false;
|
||||
final maxScroll = _scrollController.position.maxScrollExtent;
|
||||
final currentScroll = _scrollController.offset;
|
||||
return currentScroll >= (maxScroll * 0.9);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _cotisationsBloc,
|
||||
child: BlocBuilder<CotisationsBloc, CotisationsState>(
|
||||
builder: (context, state) {
|
||||
// Utilisation de UnifiedPageLayout pour améliorer la cohérence
|
||||
// tout en conservant le header personnalisé et toutes les fonctionnalités
|
||||
return UnifiedPageLayout(
|
||||
title: 'Cotisations',
|
||||
subtitle: 'Gérez les cotisations de vos membres',
|
||||
icon: Icons.payment_rounded,
|
||||
iconColor: AppTheme.accentColor,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CotisationsSearchPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Rechercher',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.filter_list),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CotisationsSearchPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Filtrer',
|
||||
),
|
||||
],
|
||||
isLoading: state is CotisationsInitial ||
|
||||
(state is CotisationsLoading && !state.isRefreshing),
|
||||
errorMessage: state is CotisationsError ? state.message : null,
|
||||
onRefresh: () {
|
||||
_cotisationsBloc.add(const LoadCotisations(refresh: true));
|
||||
_cotisationsBloc.add(const LoadCotisationsStats());
|
||||
},
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
// TODO: Implémenter la création de cotisation
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Création de cotisation - En cours de développement'),
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
body: _buildContent(state),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit le contenu principal en fonction de l'état
|
||||
/// CONSERVÉ: Toute la logique d'état et les widgets spécialisés
|
||||
Widget _buildContent(CotisationsState state) {
|
||||
if (state is CotisationsError) {
|
||||
return _buildErrorState(state);
|
||||
}
|
||||
|
||||
if (state is CotisationsLoaded) {
|
||||
return _buildLoadedState(state);
|
||||
}
|
||||
|
||||
// État par défaut - Coming Soon avec toutes les fonctionnalités prévues
|
||||
return const ComingSoonPage(
|
||||
title: 'Module Cotisations',
|
||||
description: 'Gestion complète des cotisations avec paiements automatiques',
|
||||
icon: Icons.payment_rounded,
|
||||
color: AppTheme.accentColor,
|
||||
features: [
|
||||
'Tableau de bord des cotisations',
|
||||
'Relances automatiques par email/SMS',
|
||||
'Paiements en ligne sécurisés',
|
||||
'Génération de reçus automatique',
|
||||
'Suivi des retards de paiement',
|
||||
'Rapports financiers détaillés',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(16, 50, 16, 16),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.accentColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(20),
|
||||
bottomRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Cotisations',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search, color: Colors.white),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CotisationsSearchPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.filter_list, color: Colors.white),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CotisationsSearchPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Gérez les cotisations de vos membres',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadedState(CotisationsLoaded state) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
_cotisationsBloc.add(const LoadCotisations(refresh: true));
|
||||
_cotisationsBloc.add(const LoadCotisationsStats());
|
||||
},
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
// Statistiques
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: BlocBuilder<CotisationsBloc, CotisationsState>(
|
||||
buildWhen: (previous, current) => current is CotisationsStatsLoaded,
|
||||
builder: (context, statsState) {
|
||||
if (statsState is CotisationsStatsLoaded) {
|
||||
return CotisationsStatsCard(statistics: statsState.statistics);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des cotisations
|
||||
if (state.filteredCotisations.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.payment_outlined,
|
||||
size: 64,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucune cotisation trouvée',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Commencez par créer une cotisation',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index >= state.filteredCotisations.length) {
|
||||
return state.hasReachedMax
|
||||
? const SizedBox.shrink()
|
||||
: const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final cotisation = state.filteredCotisations[index];
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
index == 0 ? 0 : 8,
|
||||
16,
|
||||
index == state.filteredCotisations.length - 1 ? 16 : 8,
|
||||
),
|
||||
child: CotisationCard(
|
||||
cotisation: cotisation,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CotisationDetailPage(
|
||||
cotisation: cotisation,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onPay: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CotisationDetailPage(
|
||||
cotisation: cotisation,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: state.filteredCotisations.length +
|
||||
(state.hasReachedMax ? 0 : 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(CotisationsError state) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppTheme.errorColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Erreur de chargement',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
_cotisationsBloc.add(const LoadCotisations(refresh: true));
|
||||
_cotisationsBloc.add(const LoadCotisationsStats());
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,596 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../shared/widgets/unified_components.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../bloc/cotisations_bloc.dart';
|
||||
import '../bloc/cotisations_event.dart';
|
||||
import '../bloc/cotisations_state.dart';
|
||||
import 'cotisation_create_page.dart';
|
||||
import 'payment_history_page.dart';
|
||||
import 'cotisation_detail_page.dart';
|
||||
import '../widgets/wave_payment_widget.dart';
|
||||
|
||||
/// Page des cotisations UnionFlow - Version Unifiée
|
||||
///
|
||||
/// Utilise l'architecture unifiée pour une expérience cohérente :
|
||||
/// - Composants standardisés réutilisables
|
||||
/// - Interface homogène avec les autres onglets
|
||||
/// - Performance optimisée avec animations fluides
|
||||
/// - Maintenabilité maximale
|
||||
class CotisationsListPageUnified extends StatefulWidget {
|
||||
const CotisationsListPageUnified({super.key});
|
||||
|
||||
@override
|
||||
State<CotisationsListPageUnified> createState() => _CotisationsListPageUnifiedState();
|
||||
}
|
||||
|
||||
class _CotisationsListPageUnifiedState extends State<CotisationsListPageUnified> {
|
||||
late final CotisationsBloc _cotisationsBloc;
|
||||
String _currentFilter = 'all';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cotisationsBloc = getIt<CotisationsBloc>();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _loadData() {
|
||||
_cotisationsBloc.add(const LoadCotisations());
|
||||
_cotisationsBloc.add(const LoadCotisationsStats());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _cotisationsBloc,
|
||||
child: BlocBuilder<CotisationsBloc, CotisationsState>(
|
||||
builder: (context, state) {
|
||||
return UnifiedPageLayout(
|
||||
title: 'Cotisations',
|
||||
subtitle: 'Gestion des cotisations de l\'association',
|
||||
icon: Icons.account_balance_wallet,
|
||||
iconColor: AppTheme.successColor,
|
||||
isLoading: state is CotisationsLoading,
|
||||
errorMessage: state is CotisationsError ? state.message : null,
|
||||
onRefresh: _loadData,
|
||||
actions: _buildActions(),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildKPISection(state),
|
||||
const SizedBox(height: AppTheme.spacingLarge),
|
||||
_buildQuickActionsSection(),
|
||||
const SizedBox(height: AppTheme.spacingLarge),
|
||||
_buildFiltersSection(),
|
||||
const SizedBox(height: AppTheme.spacingLarge),
|
||||
Expanded(child: _buildCotisationsList(state)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Actions de la barre d'outils
|
||||
List<Widget> _buildActions() {
|
||||
return [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
// TODO: Navigation vers ajout cotisation
|
||||
},
|
||||
tooltip: 'Nouvelle cotisation',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
// TODO: Navigation vers recherche
|
||||
},
|
||||
tooltip: 'Rechercher',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.analytics),
|
||||
onPressed: () {
|
||||
// TODO: Navigation vers analyses
|
||||
},
|
||||
tooltip: 'Analyses',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Section des KPI des cotisations
|
||||
Widget _buildKPISection(CotisationsState state) {
|
||||
final cotisations = state is CotisationsLoaded ? state.cotisations : <CotisationModel>[];
|
||||
final totalCotisations = cotisations.length;
|
||||
final cotisationsPayees = cotisations.where((c) => c.statut == 'PAYEE').length;
|
||||
final cotisationsEnAttente = cotisations.where((c) => c.statut == 'EN_ATTENTE').length;
|
||||
final montantTotal = cotisations.fold<double>(0, (sum, c) => sum + c.montantDu);
|
||||
|
||||
final kpis = [
|
||||
UnifiedKPIData(
|
||||
title: 'Total',
|
||||
value: totalCotisations.toString(),
|
||||
icon: Icons.receipt,
|
||||
color: AppTheme.primaryColor,
|
||||
trend: UnifiedKPITrend(
|
||||
direction: UnifiedKPITrendDirection.stable,
|
||||
value: 'Total',
|
||||
label: 'cotisations',
|
||||
),
|
||||
),
|
||||
UnifiedKPIData(
|
||||
title: 'Payées',
|
||||
value: cotisationsPayees.toString(),
|
||||
icon: Icons.check_circle,
|
||||
color: AppTheme.successColor,
|
||||
trend: UnifiedKPITrend(
|
||||
direction: UnifiedKPITrendDirection.up,
|
||||
value: '${((cotisationsPayees / totalCotisations) * 100).toInt()}%',
|
||||
label: 'du total',
|
||||
),
|
||||
),
|
||||
UnifiedKPIData(
|
||||
title: 'En attente',
|
||||
value: cotisationsEnAttente.toString(),
|
||||
icon: Icons.pending,
|
||||
color: AppTheme.warningColor,
|
||||
trend: UnifiedKPITrend(
|
||||
direction: UnifiedKPITrendDirection.down,
|
||||
value: '${((cotisationsEnAttente / totalCotisations) * 100).toInt()}%',
|
||||
label: 'du total',
|
||||
),
|
||||
),
|
||||
UnifiedKPIData(
|
||||
title: 'Montant',
|
||||
value: '${montantTotal.toStringAsFixed(0)}€',
|
||||
icon: Icons.euro,
|
||||
color: AppTheme.accentColor,
|
||||
trend: UnifiedKPITrend(
|
||||
direction: UnifiedKPITrendDirection.up,
|
||||
value: 'Total',
|
||||
label: 'collecté',
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return UnifiedKPISection(
|
||||
title: 'Statistiques des cotisations',
|
||||
kpis: kpis,
|
||||
);
|
||||
}
|
||||
|
||||
/// Section des actions rapides
|
||||
Widget _buildQuickActionsSection() {
|
||||
final actions = [
|
||||
UnifiedQuickAction(
|
||||
id: 'add_cotisation',
|
||||
title: 'Nouvelle\nCotisation',
|
||||
icon: Icons.add_card,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
UnifiedQuickAction(
|
||||
id: 'bulk_payment',
|
||||
title: 'Paiement\nGroupé',
|
||||
icon: Icons.payment,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
UnifiedQuickAction(
|
||||
id: 'send_reminder',
|
||||
title: 'Envoyer\nRappels',
|
||||
icon: Icons.notification_important,
|
||||
color: AppTheme.warningColor,
|
||||
badgeCount: 15,
|
||||
),
|
||||
UnifiedQuickAction(
|
||||
id: 'export_data',
|
||||
title: 'Exporter\nDonnées',
|
||||
icon: Icons.download,
|
||||
color: AppTheme.infoColor,
|
||||
),
|
||||
UnifiedQuickAction(
|
||||
id: 'payment_history',
|
||||
title: 'Historique\nPaiements',
|
||||
icon: Icons.history,
|
||||
color: AppTheme.accentColor,
|
||||
),
|
||||
UnifiedQuickAction(
|
||||
id: 'reports',
|
||||
title: 'Rapports\nFinanciers',
|
||||
icon: Icons.analytics,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
];
|
||||
|
||||
return UnifiedQuickActionsSection(
|
||||
title: 'Actions rapides',
|
||||
actions: actions,
|
||||
onActionTap: _handleQuickAction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Section des filtres
|
||||
Widget _buildFiltersSection() {
|
||||
return UnifiedCard.outlined(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.filter_list,
|
||||
color: AppTheme.successColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingSmall),
|
||||
Text(
|
||||
'Filtres rapides',
|
||||
style: AppTheme.titleSmall.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingMedium),
|
||||
Wrap(
|
||||
spacing: AppTheme.spacingSmall,
|
||||
runSpacing: AppTheme.spacingSmall,
|
||||
children: [
|
||||
_buildFilterChip('Toutes', 'all'),
|
||||
_buildFilterChip('Payées', 'payee'),
|
||||
_buildFilterChip('En attente', 'en_attente'),
|
||||
_buildFilterChip('En retard', 'en_retard'),
|
||||
_buildFilterChip('Annulées', 'annulee'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un chip de filtre
|
||||
Widget _buildFilterChip(String label, String value) {
|
||||
final isSelected = _currentFilter == value;
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_currentFilter = selected ? value : 'all';
|
||||
});
|
||||
// TODO: Appliquer le filtre
|
||||
},
|
||||
selectedColor: AppTheme.successColor.withOpacity(0.2),
|
||||
checkmarkColor: AppTheme.successColor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Liste des cotisations avec composant unifié
|
||||
Widget _buildCotisationsList(CotisationsState state) {
|
||||
if (state is CotisationsLoaded) {
|
||||
final filteredCotisations = _filterCotisations(state.cotisations);
|
||||
|
||||
return UnifiedListWidget<CotisationModel>(
|
||||
items: filteredCotisations,
|
||||
itemBuilder: (context, cotisation, index) => _buildCotisationCard(cotisation),
|
||||
isLoading: false,
|
||||
hasReachedMax: state.hasReachedMax,
|
||||
enableAnimations: true,
|
||||
emptyMessage: 'Aucune cotisation trouvée',
|
||||
emptyIcon: Icons.receipt_outlined,
|
||||
onLoadMore: () {
|
||||
// TODO: Charger plus de cotisations
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(
|
||||
child: Text('Chargement des cotisations...'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Filtre les cotisations selon le filtre actuel
|
||||
List<CotisationModel> _filterCotisations(List<CotisationModel> cotisations) {
|
||||
if (_currentFilter == 'all') return cotisations;
|
||||
|
||||
return cotisations.where((cotisation) {
|
||||
switch (_currentFilter) {
|
||||
case 'payee':
|
||||
return cotisation.statut == 'PAYEE';
|
||||
case 'en_attente':
|
||||
return cotisation.statut == 'EN_ATTENTE';
|
||||
case 'en_retard':
|
||||
return cotisation.statut == 'EN_RETARD';
|
||||
case 'annulee':
|
||||
return cotisation.statut == 'ANNULEE';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Construit une carte de cotisation
|
||||
Widget _buildCotisationCard(CotisationModel cotisation) {
|
||||
return UnifiedCard.listItem(
|
||||
onTap: () {
|
||||
// TODO: Navigation vers détails de la cotisation
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingSmall),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor(cotisation.statut).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Icon(
|
||||
_getStatusIcon(cotisation.statut),
|
||||
color: _getStatusColor(cotisation.statut),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingMedium),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
cotisation.typeCotisation,
|
||||
style: AppTheme.bodyLarge.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingXSmall),
|
||||
Text(
|
||||
'Membre: ${cotisation.nomMembre ?? 'N/A'}',
|
||||
style: AppTheme.bodySmall.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${cotisation.montantDu.toStringAsFixed(2)}€',
|
||||
style: AppTheme.titleMedium.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingXSmall),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingSmall,
|
||||
vertical: AppTheme.spacingXSmall,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor(cotisation.statut).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Text(
|
||||
_getStatusLabel(cotisation.statut),
|
||||
style: AppTheme.bodySmall.copyWith(
|
||||
color: _getStatusColor(cotisation.statut),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingMedium),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 16,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingXSmall),
|
||||
Text(
|
||||
'Échéance: ${cotisation.dateEcheance.day}/${cotisation.dateEcheance.month}/${cotisation.dateEcheance.year}',
|
||||
style: AppTheme.bodySmall.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (cotisation.datePaiement != null) ...[
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 16,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingXSmall),
|
||||
Text(
|
||||
'Payée le ${cotisation.datePaiement!.day}/${cotisation.datePaiement!.month}/${cotisation.datePaiement!.year}',
|
||||
style: AppTheme.bodySmall.copyWith(
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient la couleur du statut
|
||||
Color _getStatusColor(String statut) {
|
||||
switch (statut) {
|
||||
case 'PAYEE':
|
||||
return AppTheme.successColor;
|
||||
case 'EN_ATTENTE':
|
||||
return AppTheme.warningColor;
|
||||
case 'EN_RETARD':
|
||||
return AppTheme.errorColor;
|
||||
case 'ANNULEE':
|
||||
return AppTheme.textSecondary;
|
||||
default:
|
||||
return AppTheme.textSecondary;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient l'icône du statut
|
||||
IconData _getStatusIcon(String statut) {
|
||||
switch (statut) {
|
||||
case 'PAYEE':
|
||||
return Icons.check_circle;
|
||||
case 'EN_ATTENTE':
|
||||
return Icons.pending;
|
||||
case 'EN_RETARD':
|
||||
return Icons.warning;
|
||||
case 'ANNULEE':
|
||||
return Icons.cancel;
|
||||
default:
|
||||
return Icons.help;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient le libellé du statut
|
||||
String _getStatusLabel(String statut) {
|
||||
switch (statut) {
|
||||
case 'PAYEE':
|
||||
return 'Payée';
|
||||
case 'EN_ATTENTE':
|
||||
return 'En attente';
|
||||
case 'EN_RETARD':
|
||||
return 'En retard';
|
||||
case 'ANNULEE':
|
||||
return 'Annulée';
|
||||
default:
|
||||
return 'Inconnu';
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère les actions rapides
|
||||
void _handleQuickAction(UnifiedQuickAction action) {
|
||||
switch (action.id) {
|
||||
case 'add_cotisation':
|
||||
_navigateToCreateCotisation();
|
||||
break;
|
||||
case 'bulk_payment':
|
||||
_showBulkPaymentDialog();
|
||||
break;
|
||||
case 'send_reminder':
|
||||
_showSendReminderDialog();
|
||||
break;
|
||||
case 'export_data':
|
||||
_exportCotisationsData();
|
||||
break;
|
||||
case 'payment_history':
|
||||
_navigateToPaymentHistory();
|
||||
break;
|
||||
case 'reports':
|
||||
_showReportsDialog();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation vers la création de cotisation
|
||||
void _navigateToCreateCotisation() async {
|
||||
final result = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CotisationCreatePage(),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
// Recharger la liste si une cotisation a été créée
|
||||
_loadData();
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation vers l'historique des paiements
|
||||
void _navigateToPaymentHistory() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const PaymentHistoryPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche le dialogue de paiement groupé
|
||||
void _showBulkPaymentDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Paiement Groupé'),
|
||||
content: const Text('Fonctionnalité de paiement groupé à implémenter'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche le dialogue d'envoi de rappels
|
||||
void _showSendReminderDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Envoyer des Rappels'),
|
||||
content: const Text('Fonctionnalité d\'envoi de rappels à implémenter'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Export des données de cotisations
|
||||
void _exportCotisationsData() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Fonctionnalité d\'export à implémenter'),
|
||||
backgroundColor: AppTheme.infoColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche le dialogue des rapports
|
||||
void _showReportsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Rapports Financiers'),
|
||||
content: const Text('Fonctionnalité de rapports financiers à implémenter'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cotisationsBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,498 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/buttons/buttons.dart';
|
||||
import '../../../../shared/widgets/buttons/primary_button.dart';
|
||||
import '../bloc/cotisations_bloc.dart';
|
||||
import '../bloc/cotisations_event.dart';
|
||||
import '../bloc/cotisations_state.dart';
|
||||
import '../widgets/cotisation_card.dart';
|
||||
import 'cotisation_detail_page.dart';
|
||||
|
||||
/// Page de recherche et filtrage des cotisations
|
||||
class CotisationsSearchPage extends StatefulWidget {
|
||||
const CotisationsSearchPage({super.key});
|
||||
|
||||
@override
|
||||
State<CotisationsSearchPage> createState() => _CotisationsSearchPageState();
|
||||
}
|
||||
|
||||
class _CotisationsSearchPageState extends State<CotisationsSearchPage>
|
||||
with TickerProviderStateMixin {
|
||||
late final CotisationsBloc _cotisationsBloc;
|
||||
late final TabController _tabController;
|
||||
late final AnimationController _animationController;
|
||||
|
||||
final _searchController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
String? _selectedStatut;
|
||||
String? _selectedType;
|
||||
int? _selectedAnnee;
|
||||
int? _selectedMois;
|
||||
bool _showAdvancedFilters = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cotisationsBloc = getIt<CotisationsBloc>();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scrollController.addListener(_onScroll);
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_scrollController.dispose();
|
||||
_tabController.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_isBottom) {
|
||||
final currentState = _cotisationsBloc.state;
|
||||
if (currentState is CotisationsSearchResults && !currentState.hasReachedMax) {
|
||||
_performSearch(page: currentState.currentPage + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isBottom {
|
||||
if (!_scrollController.hasClients) return false;
|
||||
final maxScroll = _scrollController.position.maxScrollExtent;
|
||||
final currentScroll = _scrollController.offset;
|
||||
return currentScroll >= (maxScroll * 0.9);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _cotisationsBloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
appBar: AppBar(
|
||||
title: const Text('Recherche'),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: Colors.white70,
|
||||
indicatorColor: Colors.white,
|
||||
tabs: const [
|
||||
Tab(text: 'Toutes', icon: Icon(Icons.list)),
|
||||
Tab(text: 'En attente', icon: Icon(Icons.schedule)),
|
||||
Tab(text: 'En retard', icon: Icon(Icons.warning)),
|
||||
Tab(text: 'Payées', icon: Icon(Icons.check_circle)),
|
||||
],
|
||||
onTap: (index) => _onTabChanged(index),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildSearchHeader(),
|
||||
if (_showAdvancedFilters) _buildAdvancedFilters(),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildSearchResults(),
|
||||
_buildSearchResults(statut: 'EN_ATTENTE'),
|
||||
_buildSearchResults(statut: 'EN_RETARD'),
|
||||
_buildSearchResults(statut: 'PAYEE'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Barre de recherche
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher par nom, référence...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_performSearch();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppTheme.backgroundLight,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
_performSearch();
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Boutons d'action
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showAdvancedFilters = !_showAdvancedFilters;
|
||||
});
|
||||
if (_showAdvancedFilters) {
|
||||
_animationController.forward();
|
||||
} else {
|
||||
_animationController.reverse();
|
||||
}
|
||||
},
|
||||
icon: Icon(_showAdvancedFilters ? Icons.expand_less : Icons.tune),
|
||||
label: Text(_showAdvancedFilters ? 'Masquer filtres' : 'Filtres avancés'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _clearAllFilters,
|
||||
icon: const Icon(Icons.clear_all),
|
||||
label: const Text('Effacer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdvancedFilters() {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: _showAdvancedFilters ? null : 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: AppTheme.borderLight),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Filtres avancés',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Grille de filtres
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 3,
|
||||
children: [
|
||||
_buildFilterDropdown(
|
||||
'Type',
|
||||
_selectedType,
|
||||
['Mensuelle', 'Annuelle', 'Exceptionnelle', 'Adhésion'],
|
||||
(value) => setState(() => _selectedType = value),
|
||||
),
|
||||
_buildFilterDropdown(
|
||||
'Année',
|
||||
_selectedAnnee?.toString(),
|
||||
List.generate(5, (i) => (DateTime.now().year - i).toString()),
|
||||
(value) => setState(() => _selectedAnnee = int.tryParse(value ?? '')),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bouton d'application des filtres
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: PrimaryButton(
|
||||
text: 'Appliquer les filtres',
|
||||
onPressed: _applyAdvancedFilters,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterDropdown(
|
||||
String label,
|
||||
String? value,
|
||||
List<String> items,
|
||||
Function(String?) onChanged,
|
||||
) {
|
||||
return DropdownButtonFormField<String>(
|
||||
value: value,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem<String>(
|
||||
value: null,
|
||||
child: Text('Tous les ${label.toLowerCase()}s'),
|
||||
),
|
||||
...items.map((item) => DropdownMenuItem<String>(
|
||||
value: item,
|
||||
child: Text(item),
|
||||
)),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResults({String? statut}) {
|
||||
return BlocBuilder<CotisationsBloc, CotisationsState>(
|
||||
builder: (context, state) {
|
||||
if (state is CotisationsLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state is CotisationsError) {
|
||||
return _buildErrorState(state);
|
||||
}
|
||||
|
||||
if (state is CotisationsSearchResults) {
|
||||
final filteredResults = statut != null
|
||||
? state.cotisations.where((c) => c.statut == statut).toList()
|
||||
: state.cotisations;
|
||||
|
||||
if (filteredResults.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => _performSearch(refresh: true),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: filteredResults.length + (state.hasReachedMax ? 0 : 1),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= filteredResults.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final cotisation = filteredResults[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: CotisationCard(
|
||||
cotisation: cotisation,
|
||||
onTap: () => _navigateToDetail(cotisation),
|
||||
onPay: () => _navigateToDetail(cotisation),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildInitialState();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInitialState() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
size: 64,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Recherchez des cotisations',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Utilisez la barre de recherche ou les filtres',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun résultat trouvé',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Essayez de modifier vos critères de recherche',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(CotisationsError state) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppTheme.errorColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Erreur de recherche',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
PrimaryButton(
|
||||
text: 'Réessayer',
|
||||
onPressed: () => _performSearch(refresh: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Actions
|
||||
void _onTabChanged(int index) {
|
||||
_performSearch(refresh: true);
|
||||
}
|
||||
|
||||
void _performSearch({int page = 0, bool refresh = false}) {
|
||||
final query = _searchController.text.trim();
|
||||
|
||||
if (query.isEmpty && !_hasActiveFilters()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final filters = <String, dynamic>{
|
||||
if (query.isNotEmpty) 'query': query,
|
||||
if (_selectedStatut != null) 'statut': _selectedStatut,
|
||||
if (_selectedType != null) 'typeCotisation': _selectedType,
|
||||
if (_selectedAnnee != null) 'annee': _selectedAnnee,
|
||||
if (_selectedMois != null) 'mois': _selectedMois,
|
||||
};
|
||||
|
||||
_cotisationsBloc.add(ApplyAdvancedFilters(filters));
|
||||
}
|
||||
|
||||
void _applyAdvancedFilters() {
|
||||
_performSearch(refresh: true);
|
||||
}
|
||||
|
||||
void _clearAllFilters() {
|
||||
setState(() {
|
||||
_searchController.clear();
|
||||
_selectedStatut = null;
|
||||
_selectedType = null;
|
||||
_selectedAnnee = null;
|
||||
_selectedMois = null;
|
||||
});
|
||||
_cotisationsBloc.add(const ResetCotisationsState());
|
||||
}
|
||||
|
||||
bool _hasActiveFilters() {
|
||||
return _selectedStatut != null ||
|
||||
_selectedType != null ||
|
||||
_selectedAnnee != null ||
|
||||
_selectedMois != null;
|
||||
}
|
||||
|
||||
void _navigateToDetail(CotisationModel cotisation) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CotisationDetailPage(cotisation: cotisation),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../core/models/payment_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/common/unified_page_layout.dart';
|
||||
import '../../../../shared/widgets/common/unified_search_bar.dart';
|
||||
import '../../../../shared/widgets/common/unified_filter_chip.dart';
|
||||
import '../../../../shared/widgets/common/unified_empty_state.dart';
|
||||
import '../../../../shared/widgets/common/unified_loading_indicator.dart';
|
||||
import '../bloc/cotisations_bloc.dart';
|
||||
import '../bloc/cotisations_event.dart';
|
||||
import '../bloc/cotisations_state.dart';
|
||||
|
||||
/// Page d'historique des paiements
|
||||
class PaymentHistoryPage extends StatefulWidget {
|
||||
final String? membreId; // Filtrer par membre (optionnel)
|
||||
|
||||
const PaymentHistoryPage({
|
||||
super.key,
|
||||
this.membreId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentHistoryPage> createState() => _PaymentHistoryPageState();
|
||||
}
|
||||
|
||||
class _PaymentHistoryPageState extends State<PaymentHistoryPage> {
|
||||
late CotisationsBloc _cotisationsBloc;
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
// Filtres
|
||||
String _selectedPeriod = 'all';
|
||||
String _selectedStatus = 'all';
|
||||
String _selectedMethod = 'all';
|
||||
|
||||
// Options de filtres
|
||||
final List<Map<String, String>> _periodOptions = [
|
||||
{'value': 'all', 'label': 'Toutes les périodes'},
|
||||
{'value': 'today', 'label': 'Aujourd\'hui'},
|
||||
{'value': 'week', 'label': 'Cette semaine'},
|
||||
{'value': 'month', 'label': 'Ce mois'},
|
||||
{'value': 'year', 'label': 'Cette année'},
|
||||
];
|
||||
|
||||
final List<Map<String, String>> _statusOptions = [
|
||||
{'value': 'all', 'label': 'Tous les statuts'},
|
||||
{'value': 'COMPLETED', 'label': 'Complété'},
|
||||
{'value': 'PENDING', 'label': 'En attente'},
|
||||
{'value': 'FAILED', 'label': 'Échoué'},
|
||||
{'value': 'CANCELLED', 'label': 'Annulé'},
|
||||
];
|
||||
|
||||
final List<Map<String, String>> _methodOptions = [
|
||||
{'value': 'all', 'label': 'Toutes les méthodes'},
|
||||
{'value': 'WAVE', 'label': 'Wave Money'},
|
||||
{'value': 'ORANGE_MONEY', 'label': 'Orange Money'},
|
||||
{'value': 'MTN_MONEY', 'label': 'MTN Money'},
|
||||
{'value': 'CASH', 'label': 'Espèces'},
|
||||
{'value': 'BANK_TRANSFER', 'label': 'Virement bancaire'},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cotisationsBloc = getIt<CotisationsBloc>();
|
||||
_loadPaymentHistory();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadPaymentHistory() {
|
||||
_cotisationsBloc.add(LoadPaymentHistory(
|
||||
membreId: widget.membreId,
|
||||
period: _selectedPeriod,
|
||||
status: _selectedStatus,
|
||||
method: _selectedMethod,
|
||||
searchQuery: _searchController.text.trim(),
|
||||
));
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
// Debounce la recherche
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (_searchController.text == query) {
|
||||
_loadPaymentHistory();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onFilterChanged() {
|
||||
_loadPaymentHistory();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _cotisationsBloc,
|
||||
child: UnifiedPageLayout(
|
||||
title: 'Historique des Paiements',
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.file_download),
|
||||
onPressed: _exportHistory,
|
||||
tooltip: 'Exporter',
|
||||
),
|
||||
],
|
||||
body: Column(
|
||||
children: [
|
||||
// Barre de recherche
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: UnifiedSearchBar(
|
||||
controller: _searchController,
|
||||
hintText: 'Rechercher par membre, référence...',
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
),
|
||||
|
||||
// Filtres
|
||||
_buildFilters(),
|
||||
|
||||
// Liste des paiements
|
||||
Expanded(
|
||||
child: BlocBuilder<CotisationsBloc, CotisationsState>(
|
||||
builder: (context, state) {
|
||||
if (state is CotisationsLoading) {
|
||||
return const UnifiedLoadingIndicator();
|
||||
} else if (state is PaymentHistoryLoaded) {
|
||||
if (state.payments.isEmpty) {
|
||||
return UnifiedEmptyState(
|
||||
icon: Icons.payment,
|
||||
title: 'Aucun paiement trouvé',
|
||||
subtitle: 'Aucun paiement ne correspond à vos critères de recherche',
|
||||
actionText: 'Réinitialiser les filtres',
|
||||
onActionPressed: _resetFilters,
|
||||
);
|
||||
}
|
||||
return _buildPaymentsList(state.payments);
|
||||
} else if (state is CotisationsError) {
|
||||
return UnifiedEmptyState(
|
||||
icon: Icons.error,
|
||||
title: 'Erreur de chargement',
|
||||
subtitle: state.message,
|
||||
actionText: 'Réessayer',
|
||||
onActionPressed: _loadPaymentHistory,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilters() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
// Filtre période
|
||||
UnifiedFilterChip(
|
||||
label: _periodOptions.firstWhere((o) => o['value'] == _selectedPeriod)['label']!,
|
||||
isSelected: _selectedPeriod != 'all',
|
||||
onTap: () => _showPeriodFilter(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Filtre statut
|
||||
UnifiedFilterChip(
|
||||
label: _statusOptions.firstWhere((o) => o['value'] == _selectedStatus)['label']!,
|
||||
isSelected: _selectedStatus != 'all',
|
||||
onTap: () => _showStatusFilter(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Filtre méthode
|
||||
UnifiedFilterChip(
|
||||
label: _methodOptions.firstWhere((o) => o['value'] == _selectedMethod)['label']!,
|
||||
isSelected: _selectedMethod != 'all',
|
||||
onTap: () => _showMethodFilter(),
|
||||
),
|
||||
|
||||
// Bouton reset
|
||||
if (_selectedPeriod != 'all' || _selectedStatus != 'all' || _selectedMethod != 'all') ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: _resetFilters,
|
||||
tooltip: 'Réinitialiser les filtres',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentsList(List<PaymentModel> payments) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: payments.length,
|
||||
itemBuilder: (context, index) {
|
||||
final payment = payments[index];
|
||||
return _buildPaymentCard(payment);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentCard(PaymentModel payment) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
onTap: () => _showPaymentDetails(payment),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête avec statut
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
payment.nomMembre ?? 'Membre inconnu',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Réf: ${payment.referenceTransaction}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildStatusChip(payment.statut),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Montant et méthode
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${payment.montant.toStringAsFixed(0)} XOF',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_getMethodLabel(payment.methodePaiement),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatDate(payment.dateCreation),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
if (payment.dateTraitement != null)
|
||||
Text(
|
||||
'Traité: ${_formatDate(payment.dateTraitement!)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Description si disponible
|
||||
if (payment.description?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
payment.description!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(String statut) {
|
||||
Color backgroundColor;
|
||||
Color textColor;
|
||||
String label;
|
||||
|
||||
switch (statut) {
|
||||
case 'COMPLETED':
|
||||
backgroundColor = AppTheme.successColor;
|
||||
textColor = Colors.white;
|
||||
label = 'Complété';
|
||||
break;
|
||||
case 'PENDING':
|
||||
backgroundColor = AppTheme.warningColor;
|
||||
textColor = Colors.white;
|
||||
label = 'En attente';
|
||||
break;
|
||||
case 'FAILED':
|
||||
backgroundColor = AppTheme.errorColor;
|
||||
textColor = Colors.white;
|
||||
label = 'Échoué';
|
||||
break;
|
||||
case 'CANCELLED':
|
||||
backgroundColor = Colors.grey;
|
||||
textColor = Colors.white;
|
||||
label = 'Annulé';
|
||||
break;
|
||||
default:
|
||||
backgroundColor = Colors.grey.shade300;
|
||||
textColor = AppTheme.textPrimary;
|
||||
label = statut;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getMethodLabel(String method) {
|
||||
switch (method) {
|
||||
case 'WAVE': return 'Wave Money';
|
||||
case 'ORANGE_MONEY': return 'Orange Money';
|
||||
case 'MTN_MONEY': return 'MTN Money';
|
||||
case 'CASH': return 'Espèces';
|
||||
case 'BANK_TRANSFER': return 'Virement bancaire';
|
||||
default: return method;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day}/${date.month}/${date.year} ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _showPeriodFilter() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _buildFilterBottomSheet(
|
||||
'Période',
|
||||
_periodOptions,
|
||||
_selectedPeriod,
|
||||
(value) {
|
||||
setState(() {
|
||||
_selectedPeriod = value;
|
||||
});
|
||||
_onFilterChanged();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showStatusFilter() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _buildFilterBottomSheet(
|
||||
'Statut',
|
||||
_statusOptions,
|
||||
_selectedStatus,
|
||||
(value) {
|
||||
setState(() {
|
||||
_selectedStatus = value;
|
||||
});
|
||||
_onFilterChanged();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMethodFilter() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _buildFilterBottomSheet(
|
||||
'Méthode de paiement',
|
||||
_methodOptions,
|
||||
_selectedMethod,
|
||||
(value) {
|
||||
setState(() {
|
||||
_selectedMethod = value;
|
||||
});
|
||||
_onFilterChanged();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterBottomSheet(
|
||||
String title,
|
||||
List<Map<String, String>> options,
|
||||
String selectedValue,
|
||||
Function(String) onSelected,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...options.map((option) {
|
||||
final isSelected = option['value'] == selectedValue;
|
||||
return ListTile(
|
||||
title: Text(option['label']!),
|
||||
trailing: isSelected ? const Icon(Icons.check, color: AppTheme.accentColor) : null,
|
||||
onTap: () {
|
||||
onSelected(option['value']!);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _resetFilters() {
|
||||
setState(() {
|
||||
_selectedPeriod = 'all';
|
||||
_selectedStatus = 'all';
|
||||
_selectedMethod = 'all';
|
||||
_searchController.clear();
|
||||
});
|
||||
_onFilterChanged();
|
||||
}
|
||||
|
||||
void _showPaymentDetails(PaymentModel payment) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
maxChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Handle
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Titre
|
||||
Text(
|
||||
'Détails du Paiement',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Contenu scrollable
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDetailRow('Référence', payment.referenceTransaction),
|
||||
_buildDetailRow('Membre', payment.nomMembre ?? 'N/A'),
|
||||
_buildDetailRow('Montant', '${payment.montant.toStringAsFixed(0)} XOF'),
|
||||
_buildDetailRow('Méthode', _getMethodLabel(payment.methodePaiement)),
|
||||
_buildDetailRow('Statut', _getStatusLabel(payment.statut)),
|
||||
_buildDetailRow('Date de création', _formatDate(payment.dateCreation)),
|
||||
if (payment.dateTraitement != null)
|
||||
_buildDetailRow('Date de traitement', _formatDate(payment.dateTraitement!)),
|
||||
if (payment.description?.isNotEmpty == true)
|
||||
_buildDetailRow('Description', payment.description!),
|
||||
if (payment.referencePaiementExterne?.isNotEmpty == true)
|
||||
_buildDetailRow('Référence externe', payment.referencePaiementExterne!),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getStatusLabel(String status) {
|
||||
switch (status) {
|
||||
case 'COMPLETED': return 'Complété';
|
||||
case 'PENDING': return 'En attente';
|
||||
case 'FAILED': return 'Échoué';
|
||||
case 'CANCELLED': return 'Annulé';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
void _exportHistory() {
|
||||
// TODO: Implémenter l'export de l'historique
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Fonctionnalité d\'export à implémenter'),
|
||||
backgroundColor: AppTheme.infoColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,668 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/services/wave_integration_service.dart';
|
||||
import '../../../../core/services/wave_payment_service.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/buttons/primary_button.dart';
|
||||
import '../../../../shared/widgets/common/unified_page_layout.dart';
|
||||
|
||||
/// Page de démonstration de l'intégration Wave Money
|
||||
/// Permet de tester toutes les fonctionnalités Wave
|
||||
class WaveDemoPage extends StatefulWidget {
|
||||
const WaveDemoPage({super.key});
|
||||
|
||||
@override
|
||||
State<WaveDemoPage> createState() => _WaveDemoPageState();
|
||||
}
|
||||
|
||||
class _WaveDemoPageState extends State<WaveDemoPage>
|
||||
with TickerProviderStateMixin {
|
||||
late WaveIntegrationService _waveIntegrationService;
|
||||
late WavePaymentService _wavePaymentService;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
final _amountController = TextEditingController(text: '5000');
|
||||
final _phoneController = TextEditingController(text: '77123456');
|
||||
final _nameController = TextEditingController(text: 'Test User');
|
||||
|
||||
bool _isLoading = false;
|
||||
String _lastResult = '';
|
||||
WavePaymentStats? _stats;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_waveIntegrationService = getIt<WaveIntegrationService>();
|
||||
_wavePaymentService = getIt<WavePaymentService>();
|
||||
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
_loadStats();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
_phoneController.dispose();
|
||||
_nameController.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return UnifiedPageLayout(
|
||||
title: 'Wave Money Demo',
|
||||
subtitle: 'Test d\'intégration Wave Money',
|
||||
showBackButton: true,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildWaveHeader(),
|
||||
const SizedBox(height: 24),
|
||||
_buildTestForm(),
|
||||
const SizedBox(height: 24),
|
||||
_buildQuickActions(),
|
||||
const SizedBox(height: 24),
|
||||
_buildStatsSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildResultSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaveHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF00D4FF), Color(0xFF0099CC)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF00D4FF).withOpacity(0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.waves,
|
||||
size: 32,
|
||||
color: Color(0xFF00D4FF),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Wave Money Integration',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Test et démonstration',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.white, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Environnement de test - Aucun paiement réel ne sera effectué',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTestForm() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.borderLight),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Paramètres de test',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Montant
|
||||
TextFormField(
|
||||
controller: _amountController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Montant (XOF)',
|
||||
prefixIcon: Icon(Icons.attach_money),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Numéro de téléphone
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Numéro Wave Money',
|
||||
prefixIcon: Icon(Icons.phone),
|
||||
prefixText: '+225 ',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Nom
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nom du payeur',
|
||||
prefixIcon: Icon(Icons.person),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Bouton de test
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: PrimaryButton(
|
||||
text: _isLoading ? 'Test en cours...' : 'Tester le paiement Wave',
|
||||
icon: _isLoading ? null : Icons.play_arrow,
|
||||
onPressed: _isLoading ? null : _testWavePayment,
|
||||
isLoading: _isLoading,
|
||||
backgroundColor: const Color(0xFF00D4FF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickActions() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.borderLight),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Actions rapides',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildActionChip(
|
||||
'Calculer frais',
|
||||
Icons.calculate,
|
||||
_calculateFees,
|
||||
),
|
||||
_buildActionChip(
|
||||
'Historique',
|
||||
Icons.history,
|
||||
_showHistory,
|
||||
),
|
||||
_buildActionChip(
|
||||
'Statistiques',
|
||||
Icons.analytics,
|
||||
_loadStats,
|
||||
),
|
||||
_buildActionChip(
|
||||
'Vider cache',
|
||||
Icons.clear_all,
|
||||
_clearCache,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionChip(String label, IconData icon, VoidCallback onPressed) {
|
||||
return ActionChip(
|
||||
avatar: Icon(icon, size: 16),
|
||||
label: Text(label),
|
||||
onPressed: onPressed,
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
side: const BorderSide(color: AppTheme.borderLight),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsSection() {
|
||||
if (_stats == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.borderLight),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Statistiques Wave Money',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 2.5,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
children: [
|
||||
_buildStatCard(
|
||||
'Total paiements',
|
||||
_stats!.totalPayments.toString(),
|
||||
Icons.payment,
|
||||
AppTheme.primaryColor,
|
||||
),
|
||||
_buildStatCard(
|
||||
'Réussis',
|
||||
_stats!.completedPayments.toString(),
|
||||
Icons.check_circle,
|
||||
AppTheme.successColor,
|
||||
),
|
||||
_buildStatCard(
|
||||
'Montant total',
|
||||
'${_stats!.totalAmount.toStringAsFixed(0)} XOF',
|
||||
Icons.attach_money,
|
||||
AppTheme.warningColor,
|
||||
),
|
||||
_buildStatCard(
|
||||
'Taux de réussite',
|
||||
'${_stats!.successRate.toStringAsFixed(1)}%',
|
||||
Icons.trending_up,
|
||||
AppTheme.infoColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String title, String value, IconData icon, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultSection() {
|
||||
if (_lastResult.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.borderLight),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Dernier résultat',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 16),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: _lastResult));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Résultat copié')),
|
||||
);
|
||||
},
|
||||
tooltip: 'Copier',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.backgroundLight,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.borderLight),
|
||||
),
|
||||
child: Text(
|
||||
_lastResult,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Actions
|
||||
Future<void> _testWavePayment() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_lastResult = '';
|
||||
});
|
||||
|
||||
try {
|
||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||
if (amount <= 0) {
|
||||
throw Exception('Montant invalide');
|
||||
}
|
||||
|
||||
// Créer une cotisation de test
|
||||
final testCotisation = CotisationModel(
|
||||
id: 'test_${DateTime.now().millisecondsSinceEpoch}',
|
||||
numeroReference: 'TEST-${DateTime.now().millisecondsSinceEpoch}',
|
||||
membreId: 'test_member',
|
||||
nomMembre: _nameController.text,
|
||||
typeCotisation: 'MENSUELLE',
|
||||
montantDu: amount,
|
||||
montantPaye: 0,
|
||||
codeDevise: 'XOF',
|
||||
dateEcheance: DateTime.now().add(const Duration(days: 30)),
|
||||
statut: 'EN_ATTENTE',
|
||||
recurrente: false,
|
||||
nombreRappels: 0,
|
||||
annee: DateTime.now().year,
|
||||
dateCreation: DateTime.now(),
|
||||
);
|
||||
|
||||
// Initier le paiement Wave
|
||||
final result = await _waveIntegrationService.initiateWavePayment(
|
||||
cotisationId: testCotisation.id,
|
||||
montant: amount,
|
||||
numeroTelephone: _phoneController.text,
|
||||
nomPayeur: _nameController.text,
|
||||
metadata: {
|
||||
'test_mode': true,
|
||||
'demo_page': true,
|
||||
},
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_lastResult = '''
|
||||
Test de paiement Wave Money
|
||||
|
||||
Résultat: ${result.success ? 'SUCCÈS' : 'ÉCHEC'}
|
||||
${result.success ? '''
|
||||
ID Paiement: ${result.payment?.id}
|
||||
Session Wave: ${result.session?.waveSessionId}
|
||||
URL Checkout: ${result.checkoutUrl}
|
||||
Montant: ${amount.toStringAsFixed(0)} XOF
|
||||
Frais: ${_wavePaymentService.calculateWaveFees(amount).toStringAsFixed(0)} XOF
|
||||
''' : '''
|
||||
Erreur: ${result.errorMessage}
|
||||
'''}
|
||||
Timestamp: ${DateTime.now().toIso8601String()}
|
||||
'''.trim();
|
||||
});
|
||||
|
||||
// Feedback haptique
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
// Recharger les statistiques
|
||||
await _loadStats();
|
||||
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_lastResult = 'Erreur lors du test: $e';
|
||||
});
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _calculateFees() {
|
||||
final amount = double.tryParse(_amountController.text) ?? 0;
|
||||
if (amount <= 0) {
|
||||
setState(() {
|
||||
_lastResult = 'Montant invalide pour le calcul des frais';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final fees = _wavePaymentService.calculateWaveFees(amount);
|
||||
final total = amount + fees;
|
||||
|
||||
setState(() {
|
||||
_lastResult = '''
|
||||
Calcul des frais Wave Money
|
||||
|
||||
Montant: ${amount.toStringAsFixed(0)} XOF
|
||||
Frais Wave: ${fees.toStringAsFixed(0)} XOF
|
||||
Total: ${total.toStringAsFixed(0)} XOF
|
||||
|
||||
Barème Wave CI 2024:
|
||||
• 0-2000 XOF: Gratuit
|
||||
• 2001-10000 XOF: 25 XOF
|
||||
• 10001-50000 XOF: 100 XOF
|
||||
• 50001-100000 XOF: 200 XOF
|
||||
• 100001-500000 XOF: 500 XOF
|
||||
• >500000 XOF: 0.1% du montant
|
||||
'''.trim();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showHistory() async {
|
||||
try {
|
||||
final history = await _waveIntegrationService.getWavePaymentHistory(limit: 10);
|
||||
|
||||
setState(() {
|
||||
_lastResult = '''
|
||||
Historique des paiements Wave (10 derniers)
|
||||
|
||||
${history.isEmpty ? 'Aucun paiement trouvé' : history.map((payment) => '''
|
||||
• ${payment.numeroReference} - ${payment.montant.toStringAsFixed(0)} XOF
|
||||
Statut: ${payment.statut}
|
||||
Date: ${payment.dateTransaction.toString().substring(0, 16)}
|
||||
''').join('\n')}
|
||||
|
||||
Total: ${history.length} paiement(s)
|
||||
'''.trim();
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_lastResult = 'Erreur lors de la récupération de l\'historique: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadStats() async {
|
||||
try {
|
||||
final stats = await _waveIntegrationService.getWavePaymentStats();
|
||||
setState(() {
|
||||
_stats = stats;
|
||||
});
|
||||
} catch (e) {
|
||||
print('Erreur lors du chargement des statistiques: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearCache() async {
|
||||
try {
|
||||
// TODO: Implémenter le nettoyage du cache
|
||||
setState(() {
|
||||
_lastResult = 'Cache Wave Money vidé avec succès';
|
||||
_stats = null;
|
||||
});
|
||||
await _loadStats();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_lastResult = 'Erreur lors du nettoyage du cache: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,697 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../core/models/payment_model.dart';
|
||||
import '../../../../core/models/wave_checkout_session_model.dart';
|
||||
import '../../../../core/services/wave_payment_service.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/buttons/primary_button.dart';
|
||||
import '../../../../shared/widgets/common/unified_page_layout.dart';
|
||||
import '../bloc/cotisations_bloc.dart';
|
||||
import '../bloc/cotisations_event.dart';
|
||||
import '../bloc/cotisations_state.dart';
|
||||
|
||||
/// Page dédiée aux paiements Wave Money
|
||||
/// Interface moderne et sécurisée pour les paiements mobiles
|
||||
class WavePaymentPage extends StatefulWidget {
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const WavePaymentPage({
|
||||
super.key,
|
||||
required this.cotisation,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WavePaymentPage> createState() => _WavePaymentPageState();
|
||||
}
|
||||
|
||||
class _WavePaymentPageState extends State<WavePaymentPage>
|
||||
with TickerProviderStateMixin {
|
||||
late CotisationsBloc _cotisationsBloc;
|
||||
late WavePaymentService _wavePaymentService;
|
||||
late AnimationController _animationController;
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<double> _slideAnimation;
|
||||
late Animation<double> _pulseAnimation;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _phoneController = TextEditingController();
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
|
||||
bool _isProcessing = false;
|
||||
bool _termsAccepted = false;
|
||||
WaveCheckoutSessionModel? _currentSession;
|
||||
String? _paymentUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cotisationsBloc = getIt<CotisationsBloc>();
|
||||
_wavePaymentService = getIt<WavePaymentService>();
|
||||
|
||||
// Animations
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
vsync: this,
|
||||
);
|
||||
_pulseController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
||||
);
|
||||
_slideAnimation = Tween<double>(begin: 50.0, end: 0.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic),
|
||||
);
|
||||
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.1).animate(
|
||||
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
_pulseController.repeat(reverse: true);
|
||||
|
||||
// Pré-remplir les champs si disponible
|
||||
_nameController.text = widget.cotisation.nomMembre;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_animationController.dispose();
|
||||
_pulseController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _cotisationsBloc,
|
||||
child: UnifiedPageLayout(
|
||||
title: 'Paiement Wave Money',
|
||||
subtitle: 'Paiement sécurisé et instantané',
|
||||
showBackButton: true,
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
child: BlocConsumer<CotisationsBloc, CotisationsState>(
|
||||
listener: _handleBlocState,
|
||||
builder: (context, state) {
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _slideAnimation.value),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildWaveHeader(),
|
||||
const SizedBox(height: 24),
|
||||
_buildCotisationSummary(),
|
||||
const SizedBox(height: 24),
|
||||
_buildPaymentForm(),
|
||||
const SizedBox(height: 24),
|
||||
_buildSecurityInfo(),
|
||||
const SizedBox(height: 24),
|
||||
_buildPaymentButton(state),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaveHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF00D4FF), Color(0xFF0099CC)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF00D4FF).withOpacity(0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ScaleTransition(
|
||||
scale: _pulseAnimation,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.waves,
|
||||
size: 32,
|
||||
color: Color(0xFF00D4FF),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Wave Money',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Paiement mobile sécurisé',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'🇨🇮 Côte d\'Ivoire',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCotisationSummary() {
|
||||
final remainingAmount = widget.cotisation.montantDu - widget.cotisation.montantPaye;
|
||||
final fees = _wavePaymentService.calculateWaveFees(remainingAmount);
|
||||
final total = remainingAmount + fees;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.borderLight),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Résumé de la cotisation',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildSummaryRow('Type', widget.cotisation.typeCotisation),
|
||||
_buildSummaryRow('Membre', widget.cotisation.nomMembre),
|
||||
_buildSummaryRow('Référence', widget.cotisation.numeroReference),
|
||||
const Divider(height: 24),
|
||||
_buildSummaryRow('Montant', '${remainingAmount.toStringAsFixed(0)} XOF'),
|
||||
_buildSummaryRow('Frais Wave', '${fees.toStringAsFixed(0)} XOF'),
|
||||
const Divider(height: 24),
|
||||
_buildSummaryRow(
|
||||
'Total à payer',
|
||||
'${total.toStringAsFixed(0)} XOF',
|
||||
isTotal: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryRow(String label, String value, {bool isTotal = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: isTotal ? 16 : 14,
|
||||
fontWeight: isTotal ? FontWeight.bold : FontWeight.normal,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: isTotal ? 16 : 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isTotal ? AppTheme.primaryColor : AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentForm() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.borderLight),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Informations de paiement',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildPhoneField(),
|
||||
const SizedBox(height: 16),
|
||||
_buildNameField(),
|
||||
const SizedBox(height: 16),
|
||||
_buildEmailField(),
|
||||
const SizedBox(height: 16),
|
||||
_buildTermsCheckbox(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhoneField() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Numéro Wave Money *',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
hintText: '77 123 45 67',
|
||||
prefixIcon: const Icon(Icons.phone_android, color: Color(0xFF00D4FF)),
|
||||
prefixText: '+225 ',
|
||||
prefixStyle: const TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppTheme.borderLight),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFF00D4FF), width: 2),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppTheme.backgroundLight,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez saisir votre numéro Wave Money';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Numéro invalide (minimum 8 chiffres)';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNameField() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Nom complet *',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Votre nom complet',
|
||||
prefixIcon: const Icon(Icons.person, color: Color(0xFF00D4FF)),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppTheme.borderLight),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFF00D4FF), width: 2),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppTheme.backgroundLight,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Veuillez saisir votre nom complet';
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
return 'Le nom doit contenir au moins 2 caractères';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmailField() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Email (optionnel)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'votre.email@exemple.com',
|
||||
prefixIcon: const Icon(Icons.email, color: Color(0xFF00D4FF)),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppTheme.borderLight),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFF00D4FF), width: 2),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppTheme.backgroundLight,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
||||
return 'Format d\'email invalide';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTermsCheckbox() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _termsAccepted,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_termsAccepted = value ?? false;
|
||||
});
|
||||
},
|
||||
activeColor: const Color(0xFF00D4FF),
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_termsAccepted = !_termsAccepted;
|
||||
});
|
||||
},
|
||||
child: const Text(
|
||||
'J\'accepte les conditions d\'utilisation de Wave Money et autorise le prélèvement du montant indiqué.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSecurityInfo() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F9FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF00D4FF).withOpacity(0.2)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00D4FF).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.security,
|
||||
color: Color(0xFF00D4FF),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Paiement 100% sécurisé',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'• Chiffrement SSL/TLS de bout en bout\n'
|
||||
'• Conformité aux standards PCI DSS\n'
|
||||
'• Aucune donnée bancaire stockée\n'
|
||||
'• Transaction instantanée et traçable',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentButton(CotisationsState state) {
|
||||
final isLoading = state is PaymentInProgress || _isProcessing;
|
||||
final canPay = _formKey.currentState?.validate() == true &&
|
||||
_termsAccepted &&
|
||||
_phoneController.text.isNotEmpty &&
|
||||
!isLoading;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: PrimaryButton(
|
||||
text: isLoading
|
||||
? 'Traitement en cours...'
|
||||
: 'Payer avec Wave Money',
|
||||
icon: isLoading ? null : Icons.waves,
|
||||
onPressed: canPay ? _processWavePayment : null,
|
||||
isLoading: isLoading,
|
||||
backgroundColor: const Color(0xFF00D4FF),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleBlocState(BuildContext context, CotisationsState state) {
|
||||
if (state is PaymentSuccess) {
|
||||
_showPaymentSuccessDialog(state.payment);
|
||||
} else if (state is PaymentFailure) {
|
||||
_showPaymentErrorDialog(state.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
void _processWavePayment() async {
|
||||
if (!_formKey.currentState!.validate() || !_termsAccepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final remainingAmount = widget.cotisation.montantDu - widget.cotisation.montantPaye;
|
||||
|
||||
// Initier le paiement Wave via le BLoC
|
||||
_cotisationsBloc.add(InitiatePayment(
|
||||
cotisationId: widget.cotisation.id,
|
||||
montant: remainingAmount,
|
||||
methodePaiement: 'WAVE',
|
||||
numeroTelephone: _phoneController.text.trim(),
|
||||
nomPayeur: _nameController.text.trim(),
|
||||
emailPayeur: _emailController.text.trim().isEmpty
|
||||
? null
|
||||
: _emailController.text.trim(),
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
_showPaymentErrorDialog('Erreur lors de l\'initiation du paiement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _showPaymentSuccessDialog(PaymentModel payment) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: AppTheme.successColor, size: 28),
|
||||
SizedBox(width: 8),
|
||||
Text('Paiement réussi !'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Votre paiement de ${payment.montant.toStringAsFixed(0)} XOF a été confirmé.'),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.backgroundLight,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Référence: ${payment.numeroReference}'),
|
||||
Text('Transaction: ${payment.numeroTransaction ?? 'N/A'}'),
|
||||
Text('Date: ${DateTime.now().toString().substring(0, 16)}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop(); // Retour à la liste
|
||||
},
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPaymentErrorDialog(String errorMessage) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.error, color: AppTheme.errorColor, size: 28),
|
||||
SizedBox(width: 8),
|
||||
Text('Erreur de paiement'),
|
||||
],
|
||||
),
|
||||
content: Text(errorMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../core/animations/loading_animations.dart';
|
||||
import 'cotisation_card.dart';
|
||||
|
||||
/// Widget animé pour afficher une liste de cotisations avec animations d'apparition
|
||||
class AnimatedCotisationList extends StatefulWidget {
|
||||
final List<CotisationModel> cotisations;
|
||||
final Function(CotisationModel)? onCotisationTap;
|
||||
final bool isLoading;
|
||||
final VoidCallback? onRefresh;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
const AnimatedCotisationList({
|
||||
super.key,
|
||||
required this.cotisations,
|
||||
this.onCotisationTap,
|
||||
this.isLoading = false,
|
||||
this.onRefresh,
|
||||
this.scrollController,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedCotisationList> createState() => _AnimatedCotisationListState();
|
||||
}
|
||||
|
||||
class _AnimatedCotisationListState extends State<AnimatedCotisationList>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _listController;
|
||||
List<AnimationController> _itemControllers = [];
|
||||
List<Animation<double>> _itemAnimations = [];
|
||||
List<Animation<Offset>> _slideAnimations = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAnimations();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedCotisationList oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.cotisations.length != oldWidget.cotisations.length) {
|
||||
_updateAnimations();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_listController.dispose();
|
||||
for (final controller in _itemControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeAnimations() {
|
||||
_listController = AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_updateAnimations();
|
||||
_listController.forward();
|
||||
}
|
||||
|
||||
void _updateAnimations() {
|
||||
// Dispose des anciens controllers s'ils existent
|
||||
if (_itemControllers.isNotEmpty) {
|
||||
for (final controller in _itemControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Créer de nouveaux controllers pour chaque élément
|
||||
_itemControllers = List.generate(
|
||||
widget.cotisations.length,
|
||||
(index) => AnimationController(
|
||||
duration: Duration(milliseconds: 400 + (index * 80)),
|
||||
vsync: this,
|
||||
),
|
||||
);
|
||||
|
||||
// Animations de fade et scale
|
||||
_itemAnimations = _itemControllers.map((controller) {
|
||||
return Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Animations de slide depuis la gauche
|
||||
_slideAnimations = _itemControllers.map((controller) {
|
||||
return Tween<Offset>(
|
||||
begin: const Offset(-0.3, 0),
|
||||
end: Offset.zero,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Démarrer les animations avec un délai progressif
|
||||
for (int i = 0; i < _itemControllers.length; i++) {
|
||||
Future.delayed(Duration(milliseconds: i * 120), () {
|
||||
if (mounted) {
|
||||
_itemControllers[i].forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.isLoading && widget.cotisations.isEmpty) {
|
||||
return _buildLoadingState();
|
||||
}
|
||||
|
||||
if (widget.cotisations.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
widget.onRefresh?.call();
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
},
|
||||
child: ListView.builder(
|
||||
controller: widget.scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: widget.cotisations.length + (widget.isLoading ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= widget.cotisations.length) {
|
||||
return _buildLoadingIndicator();
|
||||
}
|
||||
|
||||
return _buildAnimatedItem(index);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnimatedItem(int index) {
|
||||
final cotisation = widget.cotisations[index];
|
||||
|
||||
if (index >= _itemAnimations.length) {
|
||||
// Fallback pour les nouveaux éléments
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: CotisationCard(
|
||||
cotisation: cotisation,
|
||||
onTap: () => widget.onCotisationTap?.call(cotisation),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _itemAnimations[index],
|
||||
builder: (context, child) {
|
||||
return SlideTransition(
|
||||
position: _slideAnimations[index],
|
||||
child: FadeTransition(
|
||||
opacity: _itemAnimations[index],
|
||||
child: Transform.scale(
|
||||
scale: 0.9 + (0.1 * _itemAnimations[index].value),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: CotisationCard(
|
||||
cotisation: cotisation,
|
||||
onTap: () => widget.onCotisationTap?.call(cotisation),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
LoadingAnimations.pulse(),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Chargement des cotisations...',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.payment_outlined,
|
||||
size: 80,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Aucune cotisation trouvée',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Les cotisations apparaîtront ici',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingIndicator() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: LoadingAnimations.spinner(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget card pour afficher une cotisation
|
||||
class CotisationCard extends StatelessWidget {
|
||||
final CotisationModel cotisation;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onPay;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
const CotisationCard({
|
||||
super.key,
|
||||
required this.cotisation,
|
||||
this.onTap,
|
||||
this.onPay,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currencyFormat = NumberFormat.currency(
|
||||
locale: 'fr_FR',
|
||||
symbol: 'FCFA',
|
||||
decimalDigits: 0,
|
||||
);
|
||||
|
||||
final dateFormat = DateFormat('dd/MM/yyyy', 'fr_FR');
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: _getStatusColor().withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onTap?.call();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header avec statut et actions
|
||||
Row(
|
||||
children: [
|
||||
// Statut badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor().withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
cotisation.libelleStatut,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _getStatusColor(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Actions
|
||||
if (cotisation.statut == 'EN_ATTENTE' || cotisation.statut == 'EN_RETARD')
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onPay?.call();
|
||||
},
|
||||
icon: const Icon(Icons.payment, size: 20),
|
||||
color: AppTheme.successColor,
|
||||
tooltip: 'Payer',
|
||||
),
|
||||
if (onEdit != null)
|
||||
IconButton(
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
color: AppTheme.primaryColor,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
if (onDelete != null)
|
||||
IconButton(
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Icons.delete, size: 20),
|
||||
color: AppTheme.errorColor,
|
||||
tooltip: 'Supprimer',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Informations principales
|
||||
Row(
|
||||
children: [
|
||||
// Icône du type
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
cotisation.iconeTypeCotisation,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Détails
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
cotisation.libelleTypeCotisation,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
if (cotisation.nomMembre != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
cotisation.nomMembre!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (cotisation.periode != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
cotisation.periode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Montant
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
currencyFormat.format(cotisation.montantDu),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
if (cotisation.montantPaye > 0) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Payé: ${currencyFormat.format(cotisation.montantPaye)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Barre de progression du paiement
|
||||
if (cotisation.montantPaye > 0 && !cotisation.isEntierementPayee) ...[
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Progression',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${cotisation.pourcentagePaiement.toStringAsFixed(0)}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: cotisation.pourcentagePaiement / 100,
|
||||
backgroundColor: AppTheme.borderColor,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
cotisation.pourcentagePaiement >= 100
|
||||
? AppTheme.successColor
|
||||
: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// Informations d'échéance
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 16,
|
||||
color: cotisation.isEnRetard
|
||||
? AppTheme.errorColor
|
||||
: cotisation.echeanceProche
|
||||
? AppTheme.warningColor
|
||||
: AppTheme.textHint,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Échéance: ${dateFormat.format(cotisation.dateEcheance)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: cotisation.isEnRetard
|
||||
? AppTheme.errorColor
|
||||
: cotisation.echeanceProche
|
||||
? AppTheme.warningColor
|
||||
: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
if (cotisation.messageUrgence.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: cotisation.isEnRetard
|
||||
? AppTheme.errorColor.withOpacity(0.1)
|
||||
: AppTheme.warningColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
cotisation.messageUrgence,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cotisation.isEnRetard
|
||||
? AppTheme.errorColor
|
||||
: AppTheme.warningColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
// Référence
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.tag,
|
||||
size: 16,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Réf: ${cotisation.numeroReference}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getStatusColor() {
|
||||
switch (cotisation.statut) {
|
||||
case 'PAYEE':
|
||||
return AppTheme.successColor;
|
||||
case 'EN_ATTENTE':
|
||||
return AppTheme.warningColor;
|
||||
case 'EN_RETARD':
|
||||
return AppTheme.errorColor;
|
||||
case 'PARTIELLEMENT_PAYEE':
|
||||
return AppTheme.infoColor;
|
||||
case 'ANNULEE':
|
||||
return AppTheme.textHint;
|
||||
default:
|
||||
return AppTheme.textSecondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget d'affichage de la timeline d'une cotisation
|
||||
class CotisationTimelineWidget extends StatefulWidget {
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const CotisationTimelineWidget({
|
||||
super.key,
|
||||
required this.cotisation,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CotisationTimelineWidget> createState() => _CotisationTimelineWidgetState();
|
||||
}
|
||||
|
||||
class _CotisationTimelineWidgetState extends State<CotisationTimelineWidget>
|
||||
with TickerProviderStateMixin {
|
||||
late final AnimationController _animationController;
|
||||
late final List<Animation<double>> _itemAnimations;
|
||||
|
||||
List<TimelineEvent> _timelineEvents = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_generateTimelineEvents();
|
||||
|
||||
_animationController = AnimationController(
|
||||
duration: Duration(milliseconds: 300 * _timelineEvents.length),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_itemAnimations = List.generate(
|
||||
_timelineEvents.length,
|
||||
(index) => Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(
|
||||
index / _timelineEvents.length,
|
||||
(index + 1) / _timelineEvents.length,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _generateTimelineEvents() {
|
||||
_timelineEvents = [
|
||||
TimelineEvent(
|
||||
title: 'Cotisation créée',
|
||||
description: 'Cotisation ${widget.cotisation.typeCotisation} créée pour ${widget.cotisation.nomMembre}',
|
||||
date: widget.cotisation.dateCreation,
|
||||
icon: Icons.add_circle,
|
||||
color: AppTheme.primaryColor,
|
||||
isCompleted: true,
|
||||
),
|
||||
];
|
||||
|
||||
// Ajouter l'événement d'échéance
|
||||
final now = DateTime.now();
|
||||
final isOverdue = widget.cotisation.dateEcheance.isBefore(now);
|
||||
|
||||
_timelineEvents.add(
|
||||
TimelineEvent(
|
||||
title: isOverdue ? 'Échéance dépassée' : 'Échéance prévue',
|
||||
description: 'Date limite de paiement: ${_formatDate(widget.cotisation.dateEcheance)}',
|
||||
date: widget.cotisation.dateEcheance,
|
||||
icon: isOverdue ? Icons.warning : Icons.schedule,
|
||||
color: isOverdue ? AppTheme.errorColor : AppTheme.warningColor,
|
||||
isCompleted: isOverdue,
|
||||
isWarning: isOverdue,
|
||||
),
|
||||
);
|
||||
|
||||
// Ajouter les événements de paiement (simulés)
|
||||
if (widget.cotisation.montantPaye > 0) {
|
||||
_timelineEvents.add(
|
||||
TimelineEvent(
|
||||
title: 'Paiement partiel reçu',
|
||||
description: 'Montant: ${widget.cotisation.montantPaye.toStringAsFixed(0)} XOF',
|
||||
date: widget.cotisation.dateCreation.add(const Duration(days: 5)), // Simulé
|
||||
icon: Icons.payment,
|
||||
color: AppTheme.successColor,
|
||||
isCompleted: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.cotisation.isEntierementPayee) {
|
||||
_timelineEvents.add(
|
||||
TimelineEvent(
|
||||
title: 'Paiement complet',
|
||||
description: 'Cotisation entièrement payée',
|
||||
date: widget.cotisation.dateCreation.add(const Duration(days: 10)), // Simulé
|
||||
icon: Icons.check_circle,
|
||||
color: AppTheme.successColor,
|
||||
isCompleted: true,
|
||||
isSuccess: true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Ajouter les événements futurs
|
||||
if (!isOverdue) {
|
||||
_timelineEvents.add(
|
||||
TimelineEvent(
|
||||
title: 'Rappel automatique',
|
||||
description: 'Rappel envoyé 3 jours avant l\'échéance',
|
||||
date: widget.cotisation.dateEcheance.subtract(const Duration(days: 3)),
|
||||
icon: Icons.notifications,
|
||||
color: AppTheme.infoColor,
|
||||
isCompleted: false,
|
||||
isFuture: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_timelineEvents.add(
|
||||
TimelineEvent(
|
||||
title: 'Paiement en attente',
|
||||
description: 'En attente du paiement complet',
|
||||
date: DateTime.now(),
|
||||
icon: Icons.hourglass_empty,
|
||||
color: AppTheme.textSecondary,
|
||||
isCompleted: false,
|
||||
isFuture: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Trier par date
|
||||
_timelineEvents.sort((a, b) => a.date.compareTo(b.date));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_timelineEvents.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Aucun historique disponible',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Historique de la cotisation',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _timelineEvents.length,
|
||||
itemBuilder: (context, index) {
|
||||
return AnimatedBuilder(
|
||||
animation: _itemAnimations[index],
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(
|
||||
0,
|
||||
50 * (1 - _itemAnimations[index].value),
|
||||
),
|
||||
child: Opacity(
|
||||
opacity: _itemAnimations[index].value,
|
||||
child: _buildTimelineItem(
|
||||
_timelineEvents[index],
|
||||
index,
|
||||
index == _timelineEvents.length - 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimelineItem(TimelineEvent event, int index, bool isLast) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Timeline indicator
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: event.isCompleted
|
||||
? event.color
|
||||
: event.color.withOpacity(0.2),
|
||||
border: Border.all(
|
||||
color: event.color,
|
||||
width: event.isCompleted ? 0 : 2,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
event.icon,
|
||||
size: 20,
|
||||
color: event.isCompleted
|
||||
? Colors.white
|
||||
: event.color,
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
Container(
|
||||
width: 2,
|
||||
height: 60,
|
||||
color: event.isCompleted
|
||||
? event.color.withOpacity(0.3)
|
||||
: AppTheme.borderLight,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Event content
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: _getEventBackgroundColor(event),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: event.color.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: event.isCompleted
|
||||
? AppTheme.textPrimary
|
||||
: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (event.isSuccess)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.successColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'Terminé',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (event.isWarning)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.errorColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'En retard',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.errorColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
event.description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: event.isCompleted
|
||||
? AppTheme.textSecondary
|
||||
: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 16,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDateTime(event.date),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
if (event.isFuture) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.infoColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'À venir',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.infoColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Color _getEventBackgroundColor(TimelineEvent event) {
|
||||
if (event.isSuccess) {
|
||||
return AppTheme.successColor.withOpacity(0.05);
|
||||
}
|
||||
if (event.isWarning) {
|
||||
return AppTheme.errorColor.withOpacity(0.05);
|
||||
}
|
||||
if (event.isFuture) {
|
||||
return AppTheme.infoColor.withOpacity(0.05);
|
||||
}
|
||||
return Colors.white;
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime date) {
|
||||
return '${_formatDate(date)} à ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Modèle pour les événements de la timeline
|
||||
class TimelineEvent {
|
||||
final String title;
|
||||
final String description;
|
||||
final DateTime date;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final bool isCompleted;
|
||||
final bool isSuccess;
|
||||
final bool isWarning;
|
||||
final bool isFuture;
|
||||
|
||||
TimelineEvent({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.date,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.isCompleted = false,
|
||||
this.isSuccess = false,
|
||||
this.isWarning = false,
|
||||
this.isFuture = false,
|
||||
});
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget pour afficher les statistiques des cotisations
|
||||
class CotisationsStatsCard extends StatelessWidget {
|
||||
final Map<String, dynamic> statistics;
|
||||
|
||||
const CotisationsStatsCard({
|
||||
super.key,
|
||||
required this.statistics,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currencyFormat = NumberFormat.currency(
|
||||
locale: 'fr_FR',
|
||||
symbol: 'FCFA',
|
||||
decimalDigits: 0,
|
||||
);
|
||||
|
||||
final totalCotisations = statistics['totalCotisations'] as int? ?? 0;
|
||||
final cotisationsPayees = statistics['cotisationsPayees'] as int? ?? 0;
|
||||
final cotisationsEnRetard = statistics['cotisationsEnRetard'] as int? ?? 0;
|
||||
final tauxPaiement = statistics['tauxPaiement'] as double? ?? 0.0;
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accentColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.analytics,
|
||||
size: 18,
|
||||
color: AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Statistiques des cotisations',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Grille des statistiques
|
||||
Row(
|
||||
children: [
|
||||
// Total des cotisations
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.receipt_long,
|
||||
label: 'Total',
|
||||
value: totalCotisations.toString(),
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Cotisations payées
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.check_circle,
|
||||
label: 'Payées',
|
||||
value: cotisationsPayees.toString(),
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
// Cotisations en retard
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.warning,
|
||||
label: 'En retard',
|
||||
value: cotisationsEnRetard.toString(),
|
||||
color: AppTheme.errorColor,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Taux de paiement
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.trending_up,
|
||||
label: 'Taux paiement',
|
||||
value: '${tauxPaiement.toStringAsFixed(1)}%',
|
||||
color: tauxPaiement >= 80
|
||||
? AppTheme.successColor
|
||||
: tauxPaiement >= 60
|
||||
? AppTheme.warningColor
|
||||
: AppTheme.errorColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Barre de progression globale
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Progression globale',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${tauxPaiement.toStringAsFixed(1)}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: tauxPaiement / 100,
|
||||
backgroundColor: AppTheme.borderColor,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
tauxPaiement >= 80
|
||||
? AppTheme.successColor
|
||||
: tauxPaiement >= 60
|
||||
? AppTheme.warningColor
|
||||
: AppTheme.errorColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Montants si disponibles
|
||||
if (statistics.containsKey('montantTotal') ||
|
||||
statistics.containsKey('montantPaye')) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
if (statistics.containsKey('montantTotal')) ...[
|
||||
Expanded(
|
||||
child: _buildMoneyStatItem(
|
||||
label: 'Montant total',
|
||||
value: currencyFormat.format(
|
||||
(statistics['montantTotal'] as num?)?.toDouble() ?? 0.0
|
||||
),
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (statistics.containsKey('montantTotal') &&
|
||||
statistics.containsKey('montantPaye'))
|
||||
const SizedBox(width: 12),
|
||||
|
||||
if (statistics.containsKey('montantPaye')) ...[
|
||||
Expanded(
|
||||
child: _buildMoneyStatItem(
|
||||
label: 'Montant payé',
|
||||
value: currencyFormat.format(
|
||||
(statistics['montantPaye'] as num?)?.toDouble() ?? 0.0
|
||||
),
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 24,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMoneyStatItem({
|
||||
required String label,
|
||||
required String value,
|
||||
required Color color,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/buttons/buttons.dart';
|
||||
import '../../../../shared/widgets/buttons/primary_button.dart';
|
||||
import 'payment_method_selector.dart';
|
||||
|
||||
/// Widget de formulaire de paiement
|
||||
class PaymentFormWidget extends StatefulWidget {
|
||||
final CotisationModel cotisation;
|
||||
final Function(Map<String, dynamic>) onPaymentInitiated;
|
||||
|
||||
const PaymentFormWidget({
|
||||
super.key,
|
||||
required this.cotisation,
|
||||
required this.onPaymentInitiated,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentFormWidget> createState() => _PaymentFormWidgetState();
|
||||
}
|
||||
|
||||
class _PaymentFormWidgetState extends State<PaymentFormWidget>
|
||||
with TickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _phoneController = TextEditingController();
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _amountController = TextEditingController();
|
||||
|
||||
late final AnimationController _animationController;
|
||||
late final Animation<Offset> _slideAnimation;
|
||||
|
||||
String? _selectedPaymentMethod;
|
||||
bool _isProcessing = false;
|
||||
bool _acceptTerms = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
);
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.3),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
// Initialiser le montant avec le montant restant à payer
|
||||
final remainingAmount = widget.cotisation.montantDu - widget.cotisation.montantPaye;
|
||||
_amountController.text = remainingAmount.toStringAsFixed(0);
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_amountController.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Sélection de la méthode de paiement
|
||||
PaymentMethodSelector(
|
||||
selectedMethod: _selectedPaymentMethod,
|
||||
montant: double.tryParse(_amountController.text) ?? 0,
|
||||
onMethodSelected: (method) {
|
||||
setState(() {
|
||||
_selectedPaymentMethod = method;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
if (_selectedPaymentMethod != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
_buildPaymentForm(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentForm() {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Informations de paiement',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Montant à payer
|
||||
_buildAmountField(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Numéro de téléphone (pour Mobile Money)
|
||||
if (_isMobileMoneyMethod()) ...[
|
||||
_buildPhoneField(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Nom du payeur
|
||||
_buildNameField(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Email (optionnel)
|
||||
_buildEmailField(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Conditions d'utilisation
|
||||
_buildTermsCheckbox(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Bouton de paiement
|
||||
_buildPaymentButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAmountField() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Montant à payer',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _amountController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(8),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Entrez le montant',
|
||||
suffixText: 'XOF',
|
||||
prefixIcon: const Icon(Icons.attach_money),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppTheme.primaryColor, width: 2),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer un montant';
|
||||
}
|
||||
final amount = double.tryParse(value);
|
||||
if (amount == null || amount <= 0) {
|
||||
return 'Montant invalide';
|
||||
}
|
||||
final remaining = widget.cotisation.montantDu - widget.cotisation.montantPaye;
|
||||
if (amount > remaining) {
|
||||
return 'Montant supérieur au solde restant (${remaining.toStringAsFixed(0)} XOF)';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (value) {
|
||||
setState(() {}); // Recalculer les frais
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhoneField() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Numéro ${_getPaymentMethodName()}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Ex: 0123456789',
|
||||
prefixIcon: const Icon(Icons.phone),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppTheme.primaryColor, width: 2),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer votre numéro de téléphone';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Numéro de téléphone invalide';
|
||||
}
|
||||
if (!_validatePhoneForMethod(value)) {
|
||||
return 'Ce numéro n\'est pas compatible avec ${_getPaymentMethodName()}';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNameField() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Nom du payeur',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Entrez votre nom complet',
|
||||
prefixIcon: const Icon(Icons.person),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppTheme.primaryColor, width: 2),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Veuillez entrer votre nom';
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
return 'Nom trop court';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmailField() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Email (optionnel)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'exemple@email.com',
|
||||
prefixIcon: const Icon(Icons.email),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppTheme.primaryColor, width: 2),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
||||
return 'Email invalide';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTermsCheckbox() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _acceptTerms,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_acceptTerms = value ?? false;
|
||||
});
|
||||
},
|
||||
activeColor: AppTheme.primaryColor,
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_acceptTerms = !_acceptTerms;
|
||||
});
|
||||
},
|
||||
child: const Text(
|
||||
'J\'accepte les conditions d\'utilisation et la politique de confidentialité',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentButton() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: PrimaryButton(
|
||||
text: _isProcessing
|
||||
? 'Traitement en cours...'
|
||||
: 'Confirmer le paiement',
|
||||
icon: _isProcessing ? null : Icons.payment,
|
||||
onPressed: _canProceedPayment() ? _processPayment : null,
|
||||
isLoading: _isProcessing,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _canProceedPayment() {
|
||||
return _selectedPaymentMethod != null &&
|
||||
_acceptTerms &&
|
||||
!_isProcessing &&
|
||||
_amountController.text.isNotEmpty;
|
||||
}
|
||||
|
||||
bool _isMobileMoneyMethod() {
|
||||
return _selectedPaymentMethod == 'ORANGE_MONEY' ||
|
||||
_selectedPaymentMethod == 'WAVE' ||
|
||||
_selectedPaymentMethod == 'MOOV_MONEY';
|
||||
}
|
||||
|
||||
String _getPaymentMethodName() {
|
||||
switch (_selectedPaymentMethod) {
|
||||
case 'ORANGE_MONEY':
|
||||
return 'Orange Money';
|
||||
case 'WAVE':
|
||||
return 'Wave';
|
||||
case 'MOOV_MONEY':
|
||||
return 'Moov Money';
|
||||
case 'CARTE_BANCAIRE':
|
||||
return 'Carte bancaire';
|
||||
default:
|
||||
return 'Paiement';
|
||||
}
|
||||
}
|
||||
|
||||
bool _validatePhoneForMethod(String phone) {
|
||||
final cleanNumber = phone.replaceAll(RegExp(r'[^\d]'), '');
|
||||
|
||||
switch (_selectedPaymentMethod) {
|
||||
case 'ORANGE_MONEY':
|
||||
// Orange: 07, 08, 09
|
||||
return RegExp(r'^(225)?(0[789])\d{8}$').hasMatch(cleanNumber);
|
||||
case 'WAVE':
|
||||
// Wave accepte tous les numéros ivoiriens
|
||||
return RegExp(r'^(225)?(0[1-9])\d{8}$').hasMatch(cleanNumber);
|
||||
case 'MOOV_MONEY':
|
||||
// Moov: 01, 02, 03
|
||||
return RegExp(r'^(225)?(0[123])\d{8}$').hasMatch(cleanNumber);
|
||||
default:
|
||||
return cleanNumber.length >= 8;
|
||||
}
|
||||
}
|
||||
|
||||
void _processPayment() {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
});
|
||||
|
||||
// Préparer les données de paiement
|
||||
final paymentData = {
|
||||
'montant': double.parse(_amountController.text),
|
||||
'methodePaiement': _selectedPaymentMethod!,
|
||||
'numeroTelephone': _phoneController.text,
|
||||
'nomPayeur': _nameController.text.trim(),
|
||||
'emailPayeur': _emailController.text.trim().isEmpty
|
||||
? null
|
||||
: _emailController.text.trim(),
|
||||
};
|
||||
|
||||
// Déclencher le paiement
|
||||
widget.onPaymentInitiated(paymentData);
|
||||
|
||||
// Simuler un délai de traitement
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/services/payment_service.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget de sélection des méthodes de paiement
|
||||
class PaymentMethodSelector extends StatefulWidget {
|
||||
final String? selectedMethod;
|
||||
final Function(String) onMethodSelected;
|
||||
final double montant;
|
||||
|
||||
const PaymentMethodSelector({
|
||||
super.key,
|
||||
this.selectedMethod,
|
||||
required this.onMethodSelected,
|
||||
required this.montant,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentMethodSelector> createState() => _PaymentMethodSelectorState();
|
||||
}
|
||||
|
||||
class _PaymentMethodSelectorState extends State<PaymentMethodSelector>
|
||||
with TickerProviderStateMixin {
|
||||
late final AnimationController _animationController;
|
||||
late final Animation<double> _scaleAnimation;
|
||||
|
||||
List<PaymentMethod> _paymentMethods = [];
|
||||
String? _selectedMethod;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedMethod = widget.selectedMethod;
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
_loadPaymentMethods();
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadPaymentMethods() {
|
||||
// En production, ceci viendrait du PaymentService
|
||||
_paymentMethods = [
|
||||
PaymentMethod(
|
||||
id: 'ORANGE_MONEY',
|
||||
nom: 'Orange Money',
|
||||
icone: '📱',
|
||||
couleur: '#FF6600',
|
||||
description: 'Paiement via Orange Money',
|
||||
fraisMinimum: 0,
|
||||
fraisMaximum: 1000,
|
||||
montantMinimum: 100,
|
||||
montantMaximum: 1000000,
|
||||
),
|
||||
PaymentMethod(
|
||||
id: 'WAVE',
|
||||
nom: 'Wave',
|
||||
icone: '🌊',
|
||||
couleur: '#00D4FF',
|
||||
description: 'Paiement via Wave',
|
||||
fraisMinimum: 0,
|
||||
fraisMaximum: 500,
|
||||
montantMinimum: 100,
|
||||
montantMaximum: 2000000,
|
||||
),
|
||||
PaymentMethod(
|
||||
id: 'MOOV_MONEY',
|
||||
nom: 'Moov Money',
|
||||
icone: '💙',
|
||||
couleur: '#0066CC',
|
||||
description: 'Paiement via Moov Money',
|
||||
fraisMinimum: 0,
|
||||
fraisMaximum: 800,
|
||||
montantMinimum: 100,
|
||||
montantMaximum: 1500000,
|
||||
),
|
||||
PaymentMethod(
|
||||
id: 'CARTE_BANCAIRE',
|
||||
nom: 'Carte bancaire',
|
||||
icone: '💳',
|
||||
couleur: '#4CAF50',
|
||||
description: 'Paiement par carte bancaire',
|
||||
fraisMinimum: 100,
|
||||
fraisMaximum: 2000,
|
||||
montantMinimum: 500,
|
||||
montantMaximum: 5000000,
|
||||
),
|
||||
];
|
||||
|
||||
// Filtrer les méthodes disponibles selon le montant
|
||||
_paymentMethods = _paymentMethods.where((method) {
|
||||
return widget.montant >= method.montantMinimum &&
|
||||
widget.montant <= method.montantMaximum;
|
||||
}).toList();
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Choisissez votre méthode de paiement',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (_paymentMethods.isEmpty)
|
||||
_buildNoMethodsAvailable()
|
||||
else
|
||||
_buildMethodsList(),
|
||||
|
||||
if (_selectedMethod != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildSelectedMethodInfo(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoMethodsAvailable() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.warningColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppTheme.warningColor.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber,
|
||||
size: 48,
|
||||
color: AppTheme.warningColor,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Aucune méthode de paiement disponible',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Le montant de ${widget.montant.toStringAsFixed(0)} XOF ne correspond aux limites d\'aucune méthode de paiement.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMethodsList() {
|
||||
return Column(
|
||||
children: _paymentMethods.map((method) {
|
||||
final isSelected = _selectedMethod == method.id;
|
||||
final fees = _calculateFees(method);
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => _selectMethod(method),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? _getMethodColor(method.couleur).withOpacity(0.1)
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? _getMethodColor(method.couleur)
|
||||
: AppTheme.borderLight,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
boxShadow: isSelected ? [
|
||||
BoxShadow(
|
||||
color: _getMethodColor(method.couleur).withOpacity(0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
] : null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icône de la méthode
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: _getMethodColor(method.couleur).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
method.icone,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Informations de la méthode
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
method.nom,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected
|
||||
? _getMethodColor(method.couleur)
|
||||
: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
method.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
if (fees > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Frais: ${fees.toStringAsFixed(0)} XOF',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.warningColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Indicateur de sélection
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected
|
||||
? _getMethodColor(method.couleur)
|
||||
: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? _getMethodColor(method.couleur)
|
||||
: AppTheme.borderLight,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectedMethodInfo() {
|
||||
final method = _paymentMethods.firstWhere((m) => m.id == _selectedMethod);
|
||||
final fees = _calculateFees(method);
|
||||
final total = widget.montant + fees;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: _getMethodColor(method.couleur).withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _getMethodColor(method.couleur).withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
method.icone,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Récapitulatif - ${method.nom}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _getMethodColor(method.couleur),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildSummaryRow('Montant', '${widget.montant.toStringAsFixed(0)} XOF'),
|
||||
if (fees > 0)
|
||||
_buildSummaryRow('Frais', '${fees.toStringAsFixed(0)} XOF'),
|
||||
const Divider(),
|
||||
_buildSummaryRow(
|
||||
'Total à payer',
|
||||
'${total.toStringAsFixed(0)} XOF',
|
||||
isTotal: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryRow(String label, String value, {bool isTotal = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: isTotal ? 16 : 14,
|
||||
fontWeight: isTotal ? FontWeight.bold : FontWeight.normal,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: isTotal ? 16 : 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isTotal ? AppTheme.textPrimary : AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _selectMethod(PaymentMethod method) {
|
||||
setState(() {
|
||||
_selectedMethod = method.id;
|
||||
});
|
||||
widget.onMethodSelected(method.id);
|
||||
|
||||
// Animation de feedback
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
double _calculateFees(PaymentMethod method) {
|
||||
// Simulation du calcul des frais
|
||||
switch (method.id) {
|
||||
case 'ORANGE_MONEY':
|
||||
return _calculateOrangeMoneyFees(widget.montant);
|
||||
case 'WAVE':
|
||||
return _calculateWaveFees(widget.montant);
|
||||
case 'MOOV_MONEY':
|
||||
return _calculateMoovMoneyFees(widget.montant);
|
||||
case 'CARTE_BANCAIRE':
|
||||
return _calculateCardFees(widget.montant);
|
||||
default:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
double _calculateOrangeMoneyFees(double montant) {
|
||||
if (montant <= 1000) return 0;
|
||||
if (montant <= 5000) return 25;
|
||||
if (montant <= 10000) return 50;
|
||||
if (montant <= 25000) return 100;
|
||||
if (montant <= 50000) return 200;
|
||||
return montant * 0.005; // 0.5%
|
||||
}
|
||||
|
||||
double _calculateWaveFees(double montant) {
|
||||
if (montant <= 2000) return 0;
|
||||
if (montant <= 10000) return 25;
|
||||
if (montant <= 50000) return 100;
|
||||
return montant * 0.003; // 0.3%
|
||||
}
|
||||
|
||||
double _calculateMoovMoneyFees(double montant) {
|
||||
if (montant <= 1000) return 0;
|
||||
if (montant <= 5000) return 30;
|
||||
if (montant <= 15000) return 75;
|
||||
if (montant <= 50000) return 150;
|
||||
return montant * 0.004; // 0.4%
|
||||
}
|
||||
|
||||
double _calculateCardFees(double montant) {
|
||||
return 100 + (montant * 0.025); // 100 XOF + 2.5%
|
||||
}
|
||||
|
||||
Color _getMethodColor(String colorHex) {
|
||||
return Color(int.parse(colorHex.replaceFirst('#', '0xFF')));
|
||||
}
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../core/services/wave_payment_service.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/buttons/primary_button.dart';
|
||||
import '../pages/wave_payment_page.dart';
|
||||
|
||||
/// Widget d'intégration Wave Money pour les cotisations
|
||||
/// Affiche les options de paiement Wave avec calcul des frais
|
||||
class WavePaymentWidget extends StatefulWidget {
|
||||
final CotisationModel cotisation;
|
||||
final VoidCallback? onPaymentInitiated;
|
||||
final bool showFullInterface;
|
||||
|
||||
const WavePaymentWidget({
|
||||
super.key,
|
||||
required this.cotisation,
|
||||
this.onPaymentInitiated,
|
||||
this.showFullInterface = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WavePaymentWidget> createState() => _WavePaymentWidgetState();
|
||||
}
|
||||
|
||||
class _WavePaymentWidgetState extends State<WavePaymentWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late WavePaymentService _wavePaymentService;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_wavePaymentService = getIt<WavePaymentService>();
|
||||
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: widget.showFullInterface
|
||||
? _buildFullInterface()
|
||||
: _buildCompactInterface(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFullInterface() {
|
||||
final remainingAmount = widget.cotisation.montantDu - widget.cotisation.montantPaye;
|
||||
final fees = _wavePaymentService.calculateWaveFees(remainingAmount);
|
||||
final total = remainingAmount + fees;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF00D4FF), Color(0xFF0099CC)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF00D4FF).withOpacity(0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header Wave
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.waves,
|
||||
size: 28,
|
||||
color: Color(0xFF00D4FF),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Wave Money',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Paiement mobile instantané',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'🇨🇮 CI',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Détails du paiement
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildPaymentRow('Montant', '${remainingAmount.toStringAsFixed(0)} XOF'),
|
||||
_buildPaymentRow('Frais Wave', '${fees.toStringAsFixed(0)} XOF'),
|
||||
const Divider(color: Colors.white30, height: 20),
|
||||
_buildPaymentRow(
|
||||
'Total',
|
||||
'${total.toStringAsFixed(0)} XOF',
|
||||
isTotal: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Avantages Wave
|
||||
_buildAdvantages(),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Bouton de paiement
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: PrimaryButton(
|
||||
text: 'Payer avec Wave',
|
||||
icon: Icons.payment,
|
||||
onPressed: _navigateToWavePayment,
|
||||
backgroundColor: Colors.white,
|
||||
textColor: const Color(0xFF00D4FF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactInterface() {
|
||||
final remainingAmount = widget.cotisation.montantDu - widget.cotisation.montantPaye;
|
||||
final fees = _wavePaymentService.calculateWaveFees(remainingAmount);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF00D4FF).withOpacity(0.3)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF00D4FF).withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00D4FF).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.waves,
|
||||
size: 24,
|
||||
color: Color(0xFF00D4FF),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Wave Money',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Frais: ${fees.toStringAsFixed(0)} XOF • Instantané',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
text: 'Payer',
|
||||
onPressed: _navigateToWavePayment,
|
||||
backgroundColor: const Color(0xFF00D4FF),
|
||||
isCompact: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentRow(String label, String value, {bool isTotal = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: isTotal ? 16 : 14,
|
||||
fontWeight: isTotal ? FontWeight.bold : FontWeight.normal,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: isTotal ? 16 : 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdvantages() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Pourquoi choisir Wave ?',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildAdvantageItem('⚡', 'Paiement instantané'),
|
||||
_buildAdvantageItem('🔒', 'Sécurisé et fiable'),
|
||||
_buildAdvantageItem('💰', 'Frais les plus bas'),
|
||||
_buildAdvantageItem('📱', 'Simple et rapide'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdvantageItem(String icon, String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
icon,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToWavePayment() {
|
||||
// Feedback haptique
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
// Callback si fourni
|
||||
widget.onPaymentInitiated?.call();
|
||||
|
||||
// Navigation vers la page de paiement Wave
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WavePaymentPage(cotisation: widget.cotisation),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user