Refactoring

This commit is contained in:
DahoudG
2025-09-17 17:54:06 +00:00
parent 12d514d866
commit 63fe107f98
165 changed files with 54220 additions and 276 deletions

View File

@@ -298,3 +298,23 @@ class ExportCotisations extends CotisationsEvent {
@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];
}

View File

@@ -380,3 +380,13 @@ class NotificationsScheduled extends CotisationsState {
@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];
}

View File

@@ -0,0 +1,565 @@
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,
);
},
);
}
}

View File

@@ -12,6 +12,7 @@ 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
@@ -422,18 +423,61 @@ class _CotisationDetailPageState extends State<CotisationDetailPage>
);
}
return 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'],
));
},
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'],
));
},
),
],
);
},
);

View File

@@ -11,6 +11,9 @@ 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});
@@ -64,69 +67,96 @@ class _CotisationsListPageState extends State<CotisationsListPage> {
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cotisationsBloc,
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: Column(
children: [
// Header personnalisé
_buildHeader(),
// Contenu principal
Expanded(
child: BlocBuilder<CotisationsBloc, CotisationsState>(
builder: (context, state) {
if (state is CotisationsInitial ||
(state is CotisationsLoading && !state.isRefreshing)) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state is CotisationsError) {
return _buildErrorState(state);
}
if (state is CotisationsLoaded) {
return _buildLoadedState(state);
}
// État par défaut - Coming Soon
return const ComingSoonPage(
title: 'Module Cotisations',
description: 'Gestion complète des cotisations avec paiements automatiques',
icon: Icons.payment_rounded,
color: AppTheme.accentColor,
features: [
'Tableau de bord des cotisations',
'Relances automatiques par email/SMS',
'Paiements en ligne sécurisés',
'Génération de reçus automatique',
'Suivi des retards de paiement',
'Rapports financiers détaillés',
],
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),
),
],
),
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,

View File

@@ -0,0 +1,596 @@
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();
}
}

View File

@@ -0,0 +1,612 @@
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,
),
);
}
}

View File

@@ -0,0 +1,668 @@
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';
});
}
}
}

View File

@@ -0,0 +1,697 @@
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'),
),
],
),
);
}
}

View File

@@ -0,0 +1,363 @@
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),
),
);
}
}